From 868825fcb7fd3ef60a39c118c3fb627d66df7eea Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 02:10:03 +0400 Subject: [PATCH 01/56] chore: Split business logic into service layer --- internal/app/handler/account.go | 67 ++++------ internal/app/handler/contest.go | 175 +++++++++---------------- internal/app/handler/entry.go | 50 ++----- internal/app/handler/handler.go | 15 ++- internal/app/handler/problem.go | 180 ++++++++----------------- internal/app/handler/submission.go | 203 ++++++++++------------------- internal/app/service/account.go | 96 ++++++++++++++ internal/app/service/contest.go | 177 +++++++++++++++++++++++++ internal/app/service/entry.go | 62 +++++++++ internal/app/service/errors.go | 35 +++++ internal/app/service/problem.go | 199 ++++++++++++++++++++++++++++ internal/app/service/service.go | 25 ++++ internal/app/service/submission.go | 178 +++++++++++++++++++++++++ 13 files changed, 1000 insertions(+), 462 deletions(-) create mode 100644 internal/app/service/account.go create mode 100644 internal/app/service/contest.go create mode 100644 internal/app/service/entry.go create mode 100644 internal/app/service/errors.go create mode 100644 internal/app/service/problem.go create mode 100644 internal/app/service/service.go create mode 100644 internal/app/service/submission.go diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 7978b9c..11769d1 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -2,17 +2,15 @@ package handler import ( "errors" - "fmt" "log/slog" "net/http" "strings" jwtgo "github.com/golang-jwt/jwt/v4" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" - "github.com/voidcontests/api/internal/hasher" + "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/jwt" "github.com/voidcontests/api/internal/lib/logger/sl" "github.com/voidcontests/api/pkg/requestid" @@ -20,7 +18,6 @@ import ( ) func (h *Handler) CreateAccount(c echo.Context) error { - op := "handler.CreateAccount" ctx := c.Request().Context() var body request.CreateAccount @@ -28,28 +25,20 @@ func (h *Handler) CreateAccount(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - exists, err := h.repo.User.Exists(ctx, body.Username) + id, err := h.service.Account.CreateAccount(ctx, body.Username, body.Password) if err != nil { - return fmt.Errorf("%s: can't verify that user exists or not: %v", op, err) - } - - if exists { - return Error(http.StatusConflict, "user already exists") - } - - passwordHash := hasher.Sha256String([]byte(body.Password), []byte(h.config.Security.Salt)) - user, err := h.repo.User.Create(ctx, body.Username, passwordHash) - if err != nil { - return fmt.Errorf("%s: failed to create user: %v", op, err) + if errors.Is(err, service.ErrUserAlreadyExists) { + return Error(http.StatusConflict, "user already exists") + } + return err } return c.JSON(http.StatusCreated, response.ID{ - ID: user.ID, + ID: id, }) } func (h *Handler) CreateSession(c echo.Context) error { - op := "handler.CreateSession" ctx := c.Request().Context() var body request.CreateSession @@ -57,18 +46,12 @@ func (h *Handler) CreateSession(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - passwordHash := hasher.Sha256String([]byte(body.Password), []byte(h.config.Security.Salt)) - user, err := h.repo.User.GetByCredentials(ctx, body.Username, passwordHash) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusUnauthorized, "user not found") - } - if err != nil { - return fmt.Errorf("%s: can't create user: %v", op, err) - } - - token, err := jwt.GenerateToken(user.ID, h.config.Security.SignatureKey) + token, err := h.service.Account.CreateSession(ctx, body.Username, body.Password) if err != nil { - return fmt.Errorf("%s: can't generate token: %v", op, err) + if errors.Is(err, service.ErrInvalidCredentials) { + return Error(http.StatusUnauthorized, "user not found") + } + return err } return c.JSON(http.StatusCreated, response.Token{ @@ -77,31 +60,25 @@ func (h *Handler) CreateSession(c echo.Context) error { } func (h *Handler) GetAccount(c echo.Context) error { - op := "handler.GetAccount" ctx := c.Request().Context() claims, _ := ExtractClaims(c) - user, err := h.repo.User.GetByID(ctx, claims.UserID) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusUnauthorized, "invalid or expired token") - } - if err != nil { - return fmt.Errorf("%s: can't get user: %v", op, err) - } - - role, err := h.repo.User.GetRole(ctx, claims.UserID) + accountInfo, err := h.service.Account.GetAccount(ctx, claims.UserID) if err != nil { - return fmt.Errorf("%s: can't get role: %v", op, err) + if errors.Is(err, service.ErrInvalidToken) { + return Error(http.StatusUnauthorized, "invalid or expired token") + } + return err } return c.JSON(http.StatusOK, response.Account{ - ID: user.ID, - Username: user.Username, + ID: accountInfo.User.ID, + Username: accountInfo.User.Username, Role: response.Role{ - Name: role.Name, - CreatedProblemsLimit: role.CreatedProblemsLimit, - CreatedContestsLimit: role.CreatedContestsLimit, + Name: accountInfo.Role.Name, + CreatedProblemsLimit: accountInfo.Role.CreatedProblemsLimit, + CreatedContestsLimit: accountInfo.Role.CreatedContestsLimit, }, }) } diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index aadfcbc..72b1d81 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -2,20 +2,17 @@ package handler import ( "errors" - "fmt" "net/http" - "time" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" + "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/pkg/validate" ) func (h *Handler) CreateContest(c echo.Context) error { - op := "handler.CreateContest" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -25,38 +22,24 @@ func (h *Handler) CreateContest(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - userrole, err := h.repo.User.GetRole(ctx, claims.UserID) + id, err := h.service.Contest.CreateContest(ctx, claims.UserID, body.Title, body.Description, body.StartTime, body.EndTime, body.DurationMins, body.MaxEntries, body.AllowLateJoin, body.ProblemsIDs) if err != nil { - return fmt.Errorf("%s: can't get role: %v", op, err) - } - - if userrole.Name == models.RoleBanned { - return Error(http.StatusForbidden, "you are banned from creating contests") - } - - if userrole.Name == models.RoleLimited { - cscount, err := h.repo.User.GetCreatedContestsCount(ctx, claims.UserID) - if err != nil { - return fmt.Errorf("%s: can't get created contests count: %v", op, err) - } - - if cscount >= int(userrole.CreatedContestsLimit) { + switch { + case errors.Is(err, service.ErrUserBanned): + return Error(http.StatusForbidden, "you are banned from creating contests") + case errors.Is(err, service.ErrContestsLimitExceeded): return Error(http.StatusForbidden, "contests limit exceeded") + default: + return err } } - contestID, err := h.repo.Contest.CreateWithProblemIDs(ctx, claims.UserID, body.Title, body.Description, body.StartTime, body.EndTime, body.DurationMins, body.MaxEntries, body.AllowLateJoin, body.ProblemsIDs) - if err != nil { - return fmt.Errorf("%s: can't create contest: %v", op, err) - } - return c.JSON(http.StatusCreated, response.ID{ - ID: contestID, + ID: id, }) } func (h *Handler) GetContestByID(c echo.Context) error { - op := "handler.GetContestByID" ctx := c.Request().Context() claims, authenticated := ExtractClaims(c) @@ -66,26 +49,25 @@ func (h *Handler) GetContestByID(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - contest, err := h.repo.Contest.GetByID(ctx, int32(contestID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "contest not found") - } - if err != nil { - return fmt.Errorf("%s: can't get contest: %v", op, err) + var userID *int32 + if authenticated { + userID = &claims.UserID } - if contest.EndTime.Before(time.Now()) { - if (authenticated && claims.UserID != contest.CreatorID) || !authenticated { + details, err := h.service.Contest.GetContestByID(ctx, int32(contestID), userID) + if err != nil { + switch { + case errors.Is(err, service.ErrContestNotFound): return Error(http.StatusNotFound, "contest not found") + case errors.Is(err, service.ErrContestFinished): + return Error(http.StatusNotFound, "contest not found") + default: + return err } } - problems, err := h.repo.Contest.GetProblemset(ctx, contest.ID) - if err != nil { - return fmt.Errorf("%s: can't get problemset: %v", op, err) - } - - n := len(problems) + contest := details.Contest + n := len(details.Problems) cdetailed := response.ContestDetailed{ ID: contest.ID, Title: contest.Title, @@ -95,68 +77,40 @@ func (h *Handler) GetContestByID(c echo.Context) error { ID: contest.CreatorID, Username: contest.CreatorUsername, }, - Participants: contest.Participants, - StartTime: contest.StartTime, - EndTime: contest.EndTime, - DurationMins: contest.DurationMins, - MaxEntries: contest.MaxEntries, - AllowLateJoin: contest.AllowLateJoin, - CreatedAt: contest.CreatedAt, + Participants: contest.Participants, + StartTime: contest.StartTime, + EndTime: contest.EndTime, + DurationMins: contest.DurationMins, + MaxEntries: contest.MaxEntries, + AllowLateJoin: contest.AllowLateJoin, + CreatedAt: contest.CreatedAt, + IsParticipant: details.IsParticipant, + SubmissionDeadline: details.SubmissionDeadline, } for i := range n { + p := details.Problems[i] cdetailed.Problems[i] = response.ContestProblemListItem{ - ID: problems[i].ID, - Charcode: problems[i].Charcode, + ID: p.ID, + Charcode: p.Charcode, Writer: response.User{ - ID: problems[i].WriterID, - Username: problems[i].WriterUsername, + ID: p.WriterID, + Username: p.WriterUsername, }, - Title: problems[i].Title, - Difficulty: problems[i].Difficulty, - TimeLimitMS: problems[i].TimeLimitMS, - MemoryLimitMB: problems[i].MemoryLimitMB, - Checker: problems[i].Checker, - CreatedAt: problems[i].CreatedAt, + Title: p.Title, + Difficulty: p.Difficulty, + TimeLimitMS: p.TimeLimitMS, + MemoryLimitMB: p.MemoryLimitMB, + Checker: p.Checker, + CreatedAt: p.CreatedAt, + Status: details.ProblemStatuses[p.ID], } } - // NOTE: Return contest without problem submissions - // statuses if user is not authenticated - if !authenticated { - return c.JSON(http.StatusOK, cdetailed) - } - - entry, err := h.repo.Entry.Get(ctx, contest.ID, claims.UserID) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("%s: can't get entry: %v", op, err) - } - if errors.Is(err, pgx.ErrNoRows) { - return c.JSON(http.StatusOK, cdetailed) - } - - cdetailed.IsParticipant = true - - _, deadline := AllowSubmitAt(contest, entry) - if contest.StartTime.Before(time.Now()) { - cdetailed.SubmissionDeadline = &deadline - } - - statuses, err := h.repo.Submission.GetProblemStatuses(ctx, entry.ID) - if err != nil { - return fmt.Errorf("%s: can't get submissions: %v", op, err) - } - - for i := range n { - problemID := cdetailed.Problems[i].ID - cdetailed.Problems[i].Status = statuses[problemID] - } - return c.JSON(http.StatusOK, cdetailed) } func (h *Handler) GetCreatedContests(c echo.Context) error { - op := "handler.GetCreatedContests" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -171,13 +125,13 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { offset = 0 } - contests, total, err := h.repo.Contest.GetWithCreatorID(ctx, claims.UserID, limit, offset) + result, err := h.service.Contest.ListCreatedContests(ctx, claims.UserID, limit, offset) if err != nil { - return fmt.Errorf("%s: can't get created contests: %v", op, err) + return err } items := make([]response.ContestListItem, 0) - for _, contest := range contests { + for _, contest := range result.Contests { item := response.ContestListItem{ ID: contest.ID, Creator: response.User{ @@ -197,10 +151,10 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { return c.JSON(http.StatusOK, response.Pagination[response.ContestListItem]{ Meta: response.Meta{ - Total: total, + Total: result.Total, Limit: limit, Offset: offset, - HasNext: offset+limit < total, + HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, Items: items, @@ -208,7 +162,6 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { } func (h *Handler) GetContests(c echo.Context) error { - op := "handler.GetContests" ctx := c.Request().Context() limit, ok := ExtractQueryParamInt(c, "limit") @@ -221,13 +174,13 @@ func (h *Handler) GetContests(c echo.Context) error { offset = 0 } - contests, total, err := h.repo.Contest.ListAll(ctx, limit, offset) + result, err := h.service.Contest.ListAllContests(ctx, limit, offset) if err != nil { - return fmt.Errorf("%s: can't get contests: %w", op, err) + return err } items := make([]response.ContestListItem, 0) - for _, contest := range contests { + for _, contest := range result.Contests { item := response.ContestListItem{ ID: contest.ID, Creator: response.User{ @@ -247,10 +200,10 @@ func (h *Handler) GetContests(c echo.Context) error { return c.JSON(http.StatusOK, response.Pagination[response.ContestListItem]{ Meta: response.Meta{ - Total: total, + Total: result.Total, Limit: limit, Offset: offset, - HasNext: offset+limit < total, + HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, Items: items, @@ -258,7 +211,6 @@ func (h *Handler) GetContests(c echo.Context) error { } func (h *Handler) GetLeaderboard(c echo.Context) error { - op := "handler.GetLeaderboard" ctx := c.Request().Context() contestID, ok := ExtractParamInt(c, "cid") @@ -276,27 +228,22 @@ func (h *Handler) GetLeaderboard(c echo.Context) error { offset = 0 } - _, err := h.repo.Contest.GetByID(ctx, int32(contestID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "contest not found") - } + result, err := h.service.Contest.GetLeaderboard(ctx, contestID, limit, offset) if err != nil { - return fmt.Errorf("%s: can't get contest: %v", op, err) - } - - leaderboard, total, err := h.repo.Contest.GetLeaderboard(ctx, contestID, limit, offset) - if err != nil { - return fmt.Errorf("%s: can't get leaderboard: %v", op, err) + if errors.Is(err, service.ErrContestNotFound) { + return Error(http.StatusNotFound, "contest not found") + } + return err } return c.JSON(http.StatusOK, response.Pagination[models.LeaderboardEntry]{ Meta: response.Meta{ - Total: total, + Total: result.Total, Limit: limit, Offset: offset, - HasNext: offset+limit < total, + HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, - Items: leaderboard, + Items: result.Leaderboard, }) } diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index 1f0c46d..b7180c3 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -2,16 +2,13 @@ package handler import ( "errors" - "fmt" "net/http" - "time" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" + "github.com/voidcontests/api/internal/app/service" ) func (h *Handler) CreateEntry(c echo.Context) error { - op := "handler.CreateEntry" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -21,40 +18,21 @@ func (h *Handler) CreateEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - contest, err := h.repo.Contest.GetByID(ctx, int32(contestID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "contest not found") - } - if err != nil { - return fmt.Errorf("%s: can't get contest: %v", op, err) - } - - entries, err := h.repo.Contest.GetEntriesCount(ctx, int32(contestID)) + err := h.service.Entry.CreateEntry(ctx, int32(contestID), claims.UserID) if err != nil { - return fmt.Errorf("%s: can't get entries: %v", op, err) - } - - if contest.MaxEntries != 0 && entries >= contest.MaxEntries { - return Error(http.StatusConflict, "max slots limit reached") - } - - // NOTE: disallow join if: contest already finished or (already started and no late joins) - if contest.EndTime.Before(time.Now()) || (contest.StartTime.Before(time.Now()) && !contest.AllowLateJoin) { - return Error(http.StatusForbidden, "application time is over") - } - - _, err = h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) - if errors.Is(err, pgx.ErrNoRows) { - _, err = h.repo.Entry.Create(ctx, int32(contestID), claims.UserID) - if err != nil { - return fmt.Errorf("%s: can't create entry: %v", op, err) + switch { + case errors.Is(err, service.ErrContestNotFound): + return Error(http.StatusNotFound, "contest not found") + case errors.Is(err, service.ErrMaxSlotsReached): + return Error(http.StatusConflict, "max slots limit reached") + case errors.Is(err, service.ErrApplicationTimeOver): + return Error(http.StatusForbidden, "application time is over") + case errors.Is(err, service.ErrEntryAlreadyExists): + return Error(http.StatusConflict, "user already has entry for this contest") + default: + return err } - - return c.NoContent(http.StatusCreated) - } - if err != nil { - return fmt.Errorf("%s: can't get entry: %v", op, err) } - return Error(http.StatusConflict, "user already has entry for this contest") + return c.NoContent(http.StatusCreated) } diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index 979127f..8546c77 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/labstack/echo/v4" + "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/jwt" "github.com/voidcontests/api/internal/storage/broker" @@ -12,16 +13,18 @@ import ( ) type Handler struct { - config *config.Config - repo *repository.Repository - broker broker.Broker + config *config.Config + repo *repository.Repository + broker broker.Broker + service *service.Service } func New(c *config.Config, r *repository.Repository, b broker.Broker) *Handler { return &Handler{ - config: c, - repo: r, - broker: b, + config: c, + repo: r, + broker: b, + service: service.New(c, r, b), } } diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index aa9dceb..066842b 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -2,21 +2,17 @@ package handler import ( "errors" - "fmt" "net/http" - "strings" "time" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" - "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/pkg/validate" ) func (h *Handler) CreateProblem(c echo.Context) error { - op := "handler.CreateProblem" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -26,65 +22,28 @@ func (h *Handler) CreateProblem(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - userrole, err := h.repo.User.GetRole(ctx, claims.UserID) + id, err := h.service.Problem.CreateProblem(ctx, claims.UserID, body.Title, body.Statement, body.Difficulty, body.TimeLimitMS, body.MemoryLimitMB, body.Checker, body.TestCases) if err != nil { - return fmt.Errorf("%s: can't get role: %v", op, err) - } - - if userrole.Name == models.RoleBanned { - return Error(http.StatusForbidden, "you are banned from creating problems") - } - - if userrole.Name == models.RoleLimited { - pscount, err := h.repo.User.GetCreatedProblemsCount(ctx, claims.UserID) - if err != nil { - return fmt.Errorf("%s: can't get created problems count: %v", op, err) - } - - if pscount >= int(userrole.CreatedProblemsLimit) { + switch { + case errors.Is(err, service.ErrUserBanned): + return Error(http.StatusForbidden, "you are banned from creating problems") + case errors.Is(err, service.ErrProblemsLimitExceeded): return Error(http.StatusForbidden, "problems limit exceeded") + case errors.Is(err, service.ErrInvalidTimeLimit): + return Error(http.StatusBadRequest, "time_limit_ms must be between 500 and 10000") + case errors.Is(err, service.ErrInvalidMemoryLimit): + return Error(http.StatusBadRequest, "memory_limit_mb must be between 16 and 512") + default: + return err } } - if body.TimeLimitMS < 500 || body.TimeLimitMS > 10000 { - return Error(http.StatusBadRequest, "time_limit_ms must be between 500 and 10000") - } - - if body.MemoryLimitMB < 16 || body.MemoryLimitMB > 512 { - return Error(http.StatusBadRequest, "memory_limit_mb must be between 16 and 512") - } - - // TODO: Remove examples as database entity - // Forbid to create more examples than 3 - examplesCount := 0 - for i := range body.TestCases { - if body.TestCases[i].IsExample { - examplesCount++ - } - - if examplesCount > 3 && body.TestCases[i].IsExample { - body.TestCases[i].IsExample = false - } - } - - checker := body.Checker - if checker == "" { - checker = "tokens" - } - - problemID, err := h.repo.Problem.CreateWithTCs(ctx, claims.UserID, body.Title, body.Statement, body.Difficulty, body.TimeLimitMS, body.MemoryLimitMB, checker, body.TestCases) - - if err != nil { - return fmt.Errorf("%s: can't create problem: %v", op, err) - } - return c.JSON(http.StatusCreated, response.ID{ - ID: problemID, + ID: id, }) } func (h *Handler) GetCreatedProblems(c echo.Context) error { - op := "handler.GetCreatedProblems" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -99,14 +58,14 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { offset = 0 } - ps, total, err := h.repo.Problem.GetWithWriterID(ctx, claims.UserID, limit, offset) + result, err := h.service.Problem.GetCreatedProblems(ctx, claims.UserID, limit, offset) if err != nil { - return fmt.Errorf("%s: can't get created problems: %v", op, err) + return err } - n := len(ps) + n := len(result.Problems) problems := make([]response.ProblemListItem, n, n) - for i, p := range ps { + for i, p := range result.Problems { problems[i] = response.ProblemListItem{ ID: p.ID, Title: p.Title, @@ -124,10 +83,10 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { return c.JSON(http.StatusOK, response.Pagination[response.ProblemListItem]{ Meta: response.Meta{ - Total: total, + Total: result.Total, Limit: limit, Offset: offset, - HasNext: offset+limit < total, + HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, Items: problems, @@ -135,7 +94,6 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { } func (h *Handler) GetContestProblem(c echo.Context) error { - op := "handler.GetContestProblem" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -146,59 +104,35 @@ func (h *Handler) GetContestProblem(c echo.Context) error { } charcode := c.Param("charcode") - if len(charcode) > 2 { - return Error(http.StatusBadRequest, "problem charcode couldn't be longer than 2 characters") - } - charcode = strings.ToUpper(charcode) - - contest, err := h.repo.Contest.GetByID(ctx, int32(contestID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "contest not found") - } - if err != nil { - return err - } - - now := time.Now() - if contest.StartTime.After(now) { - return Error(http.StatusForbidden, "contest not started yet") - } - - entry, err := h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusForbidden, "no entry") - } - if err != nil { - return fmt.Errorf("%s: can't get entry: %v", op, err) - } - - p, err := h.repo.Problem.Get(ctx, int32(contestID), charcode) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "problem not found") - } - if err != nil { - return fmt.Errorf("%s: can't get problem: %v", op, err) - } - etc, err := h.repo.Problem.GetExampleCases(ctx, p.ID) + details, err := h.service.Problem.GetContestProblem(ctx, int32(contestID), claims.UserID, charcode) if err != nil { - return fmt.Errorf("%s: can't get tc examples: %v", op, err) + switch { + case errors.Is(err, service.ErrInvalidCharcode): + return Error(http.StatusBadRequest, "problem charcode couldn't be longer than 2 characters") + case errors.Is(err, service.ErrContestNotFound): + return Error(http.StatusNotFound, "contest not found") + case errors.Is(err, service.ErrContestNotStarted): + return Error(http.StatusForbidden, "contest not started yet") + case errors.Is(err, service.ErrNoEntryForContest): + return Error(http.StatusForbidden, "no entry") + case errors.Is(err, service.ErrProblemNotFound): + return Error(http.StatusNotFound, "problem not found") + default: + return err + } } - n := len(etc) + p := details.Problem + n := len(details.Examples) examples := make([]response.TC, n, n) for i := 0; i < n; i++ { examples[i] = response.TC{ - Input: etc[i].Input, - Output: etc[i].Output, + Input: details.Examples[i].Input, + Output: details.Examples[i].Output, } } - status, err := h.repo.Submission.GetProblemStatus(ctx, entry.ID, p.ID) - if err != nil { - return err - } - pdetailed := response.ContestProblemDetailed{ ID: p.ID, Charcode: p.Charcode, @@ -207,7 +141,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { Statement: p.Statement, Examples: examples, Difficulty: p.Difficulty, - Status: status, + Status: details.Status, CreatedAt: p.CreatedAt, TimeLimitMS: p.TimeLimitMS, MemoryLimitMB: p.MemoryLimitMB, @@ -218,16 +152,14 @@ func (h *Handler) GetContestProblem(c echo.Context) error { }, } - _, deadline := AllowSubmitAt(contest, entry) - if contest.StartTime.Before(time.Now()) { - pdetailed.SubmissionDeadline = &deadline + if details.SubmissionWindow.Earliest.Before(time.Now()) { + pdetailed.SubmissionDeadline = &details.SubmissionWindow.Deadline } return c.JSON(http.StatusOK, pdetailed) } func (h *Handler) GetProblemByID(c echo.Context) error { - op := "handler.GetProblemByID" ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -237,29 +169,25 @@ func (h *Handler) GetProblemByID(c echo.Context) error { return Error(http.StatusBadRequest, "problem ID should be an integer") } - problem, err := h.repo.Problem.GetByID(ctx, int32(problemID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "problem not found") - } - if err != nil { - return fmt.Errorf("%s: can't get problem: %v", op, err) - } - - if problem.WriterID != claims.UserID { - return Error(http.StatusNotFound, "problem not found") - } - - etc, err := h.repo.Problem.GetExampleCases(ctx, problem.ID) + details, err := h.service.Problem.GetProblemByID(ctx, int32(problemID), claims.UserID) if err != nil { - return fmt.Errorf("%s: can't get tc examples: %v", op, err) + switch { + case errors.Is(err, service.ErrProblemNotFound): + return Error(http.StatusNotFound, "problem not found") + case errors.Is(err, service.ErrNotProblemWriter): + return Error(http.StatusNotFound, "problem not found") + default: + return err + } } - n := len(etc) + problem := details.Problem + n := len(details.Examples) examples := make([]response.TC, n, n) for i := 0; i < n; i++ { examples[i] = response.TC{ - Input: etc[i].Input, - Output: etc[i].Output, + Input: details.Examples[i].Input, + Output: details.Examples[i].Output, } } diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index 14cf7bc..728470e 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -4,16 +4,12 @@ import ( "errors" "log/slog" "net/http" - "strings" - "time" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" + "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/lib/logger/sl" - "github.com/voidcontests/api/internal/storage/models" - "github.com/voidcontests/api/internal/storage/models/status" "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/validate" ) @@ -30,10 +26,6 @@ func (h *Handler) CreateSubmission(c echo.Context) error { } charcode := c.Param("charcode") - if len(charcode) > 2 { - return Error(http.StatusBadRequest, "problem's `charcode` couldn't be longer than 2 characters") - } - charcode = strings.ToUpper(charcode) var body request.CreateSubmissionRequest if err := validate.Bind(c, &body); err != nil { @@ -41,55 +33,27 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body") } - contest, err := h.repo.Contest.GetByID(ctx, int32(contestID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "contest not found") - } + result, err := h.service.Submission.CreateSubmission(ctx, int32(contestID), claims.UserID, charcode, body.Code, body.Language) if err != nil { - log.Error("can't get contest", sl.Err(err)) - return err - } - - entry, err := h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) - if errors.Is(err, pgx.ErrNoRows) { - log.Debug("trying to create submission without entry") - return Error(http.StatusForbidden, "no entry for contest") - } - if err != nil { - log.Error("can't get entry", sl.Err(err)) - return err - } - - now := time.Now() - earliest, deadline := AllowSubmitAt(contest, entry) - if earliest.After(now) || deadline.Before(now) { - return Error(http.StatusForbidden, "submission window is currently closed") - } - - problem, err := h.repo.Problem.Get(ctx, int32(contestID), charcode) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "problem not found") - } - if err != nil { - log.Error("can't get problem", sl.Err(err)) - return err - } - - s, err := h.repo.Submission.Create(ctx, entry.ID, problem.ID, body.Code, body.Language) - if err != nil { - log.Error("can't create submission", sl.Err(err)) - return err - } - - // TODO: create initial testing report in database - - if err := h.broker.PublishSubmission(ctx, s); err != nil { - log.Error("can't publish submission", sl.Err(err)) - // TODO: if we can't push submission into execution queue, try to save it to local memory, and try to push later (?) - // - but is it really needed, after some time? - return err + switch { + case errors.Is(err, service.ErrInvalidCharcode): + return Error(http.StatusBadRequest, "problem's `charcode` couldn't be longer than 2 characters") + case errors.Is(err, service.ErrContestNotFound): + return Error(http.StatusNotFound, "contest not found") + case errors.Is(err, service.ErrNoEntryForContest): + log.Debug("trying to create submission without entry") + return Error(http.StatusForbidden, "no entry for contest") + case errors.Is(err, service.ErrSubmissionWindowClosed): + return Error(http.StatusForbidden, "submission window is currently closed") + case errors.Is(err, service.ErrProblemNotFound): + return Error(http.StatusNotFound, "problem not found") + default: + log.Error("failed to create submission", sl.Err(err)) + return err + } } + s := result.Submission return c.JSON(http.StatusCreated, response.Submission{ ID: s.ID, ProblemID: s.ProblemID, @@ -111,42 +75,41 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { return Error(http.StatusBadRequest, "submission ID should be an integer") } - s, err := h.repo.Submission.GetByID(ctx, int32(submissionID)) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusNotFound, "submission not found") - } + details, err := h.service.Submission.GetSubmissionByID(ctx, int32(submissionID)) if err != nil { - log.Error("can't get submissions", sl.Err(err)) + if errors.Is(err, service.ErrSubmissionNotFound) { + return Error(http.StatusNotFound, "submission not found") + } + log.Error("failed to get submission", sl.Err(err)) return err } - if s.Status != status.Success { + submission := details.Submission + + // If no testing report, return basic submission info + if details.TestingReport == nil { return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - Status: s.Status, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, - CreatedAt: s.CreatedAt, + ID: submission.ID, + ProblemID: submission.ProblemID, + Status: submission.Status, + Verdict: submission.Verdict, + Code: submission.Code, + Language: submission.Language, + CreatedAt: submission.CreatedAt, }) - } - tr, err := h.repo.Submission.GetTestingReport(ctx, s.ID) - if err != nil { - log.Error("can't get testing report", sl.Err(err)) - return err - } + tr := details.TestingReport - if tr.FirstFailedTestID == nil { + // If no failed test, return with testing report + if details.FailedTest == nil { return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - Status: s.Status, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, + ID: submission.ID, + ProblemID: submission.ProblemID, + Status: submission.Status, + Verdict: submission.Verdict, + Code: submission.Code, + Language: submission.Language, TestingReport: &response.TestingReport{ ID: tr.ID, PassedTestsCount: tr.PassedTestsCount, @@ -154,23 +117,19 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { Stderr: tr.Stderr, CreatedAt: tr.CreatedAt, }, - CreatedAt: s.CreatedAt, + CreatedAt: submission.CreatedAt, }) } - ftc, err := h.repo.Problem.GetTestCaseByID(ctx, *tr.FirstFailedTestID) - if err != nil { - log.Error("can't get test case", sl.Err(err)) - return err - } - + // Return with full testing report including failed test + ftc := details.FailedTest return c.JSON(http.StatusOK, response.Submission{ - ID: s.ID, - ProblemID: s.ProblemID, - Status: s.Status, - Verdict: s.Verdict, - Code: s.Code, - Language: s.Language, + ID: submission.ID, + ProblemID: submission.ProblemID, + Status: submission.Status, + Verdict: submission.Verdict, + Code: submission.Code, + Language: submission.Language, TestingReport: &response.TestingReport{ ID: tr.ID, PassedTestsCount: tr.PassedTestsCount, @@ -183,7 +142,7 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { Stderr: tr.Stderr, CreatedAt: tr.CreatedAt, }, - CreatedAt: s.CreatedAt, + CreatedAt: submission.CreatedAt, }) } @@ -199,10 +158,6 @@ func (h *Handler) GetSubmissions(c echo.Context) error { } charcode := c.Param("charcode") - if len(charcode) > 2 { - return Error(http.StatusBadRequest, "problem's `charcode` couldn't be longer than 2 characters") - } - charcode = strings.ToUpper(charcode) limit, ok := ExtractQueryParamInt(c, "limit") if !ok { @@ -214,24 +169,22 @@ func (h *Handler) GetSubmissions(c echo.Context) error { offset = 0 } - entry, err := h.repo.Entry.Get(ctx, int32(contestID), claims.UserID) - if errors.Is(err, pgx.ErrNoRows) { - return Error(http.StatusForbidden, "no entry for contest") - } - if err != nil { - log.Error("can't get entry", sl.Err(err)) - return err - } - - submissions, total, err := h.repo.Submission.ListByProblem(ctx, entry.ID, charcode, limit, offset) + result, err := h.service.Submission.ListSubmissions(ctx, int32(contestID), claims.UserID, charcode, limit, offset) if err != nil { - log.Error("can't get submissions", sl.Err(err)) - return err + switch { + case errors.Is(err, service.ErrInvalidCharcode): + return Error(http.StatusBadRequest, "problem's `charcode` couldn't be longer than 2 characters") + case errors.Is(err, service.ErrNoEntryForContest): + return Error(http.StatusForbidden, "no entry for contest") + default: + log.Error("failed to list submissions", sl.Err(err)) + return err + } } - n := len(submissions) + n := len(result.Submissions) items := make([]response.Submission, n, n) - for i, submission := range submissions { + for i, submission := range result.Submissions { items[i] = response.Submission{ ID: submission.ID, ProblemID: submission.ProblemID, @@ -243,32 +196,12 @@ func (h *Handler) GetSubmissions(c echo.Context) error { return c.JSON(http.StatusOK, response.Pagination[response.Submission]{ Meta: response.Meta{ - Total: total, + Total: result.Total, Limit: limit, Offset: offset, - HasNext: offset+limit < total, + HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, Items: items, }) } - -func AllowSubmitAt(contest models.Contest, entry models.Entry) (earliest time.Time, deadline time.Time) { - if contest.DurationMins == 0 { - return contest.StartTime, contest.EndTime - } - - earliest = entry.CreatedAt - if contest.StartTime.After(earliest) { - earliest = contest.StartTime - } - - personalDeadline := earliest.Add(time.Duration(contest.DurationMins) * time.Minute) - if personalDeadline.Before(contest.EndTime) { - deadline = personalDeadline - } else { - deadline = contest.EndTime - } - - return earliest, deadline -} diff --git a/internal/app/service/account.go b/internal/app/service/account.go new file mode 100644 index 0000000..a31a05b --- /dev/null +++ b/internal/app/service/account.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/config" + "github.com/voidcontests/api/internal/hasher" + "github.com/voidcontests/api/internal/jwt" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository" +) + +type AccountService struct { + config *config.Config + repo *repository.Repository +} + +func NewAccountService(cfg *config.Config, repo *repository.Repository) *AccountService { + return &AccountService{ + config: cfg, + repo: repo, + } +} + +func (s *AccountService) CreateAccount(ctx context.Context, username, password string) (int32, error) { + op := "service.AccountService.CreateAccount" + + exists, err := s.repo.User.Exists(ctx, username) + if err != nil { + return 0, fmt.Errorf("%s: can't verify that user exists: %w", op, err) + } + + if exists { + return 0, ErrUserAlreadyExists + } + + passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Security.Salt)) + + user, err := s.repo.User.Create(ctx, username, passwordHash) + if err != nil { + return 0, fmt.Errorf("%s: failed to create user: %w", op, err) + } + + return user.ID, nil +} + +func (s *AccountService) CreateSession(ctx context.Context, username, password string) (string, error) { + op := "service.AccountService.CreateSession" + + passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Security.Salt)) + + user, err := s.repo.User.GetByCredentials(ctx, username, passwordHash) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrInvalidCredentials + } + if err != nil { + return "", fmt.Errorf("%s: failed to get user by credentials: %w", op, err) + } + + token, err := jwt.GenerateToken(user.ID, s.config.Security.SignatureKey) + if err != nil { + return "", fmt.Errorf("%s: %w: %v", op, ErrTokenGeneration, err) + } + + return token, nil +} + +type AccountInfo struct { + User models.User + Role models.Role +} + +func (s *AccountService) GetAccount(ctx context.Context, userID int32) (*AccountInfo, error) { + op := "service.AccountService.GetAccount" + + user, err := s.repo.User.GetByID(ctx, userID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidToken + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get user: %w", op, err) + } + + role, err := s.repo.User.GetRole(ctx, userID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get role: %w", op, err) + } + + return &AccountInfo{ + User: user, + Role: role, + }, nil +} diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go new file mode 100644 index 0000000..704046e --- /dev/null +++ b/internal/app/service/contest.go @@ -0,0 +1,177 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository" +) + +type ContestService struct { + repo *repository.Repository +} + +func NewContestService(repo *repository.Repository) *ContestService { + return &ContestService{ + repo: repo, + } +} + +func (s *ContestService) CreateContest(ctx context.Context, userID int32, title, description string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool, problemIDs []int32) (int32, error) { + op := "service.ContestService.CreateContest" + + userRole, err := s.repo.User.GetRole(ctx, userID) + if err != nil { + return 0, fmt.Errorf("%s: failed to get user role: %w", op, err) + } + + if userRole.Name == models.RoleBanned { + return 0, ErrUserBanned + } + + if userRole.Name == models.RoleLimited { + contestsCount, err := s.repo.User.GetCreatedContestsCount(ctx, userID) + if err != nil { + return 0, fmt.Errorf("%s: failed to get created contests count: %w", op, err) + } + + if contestsCount >= int(userRole.CreatedContestsLimit) { + return 0, ErrContestsLimitExceeded + } + } + + contestID, err := s.repo.Contest.CreateWithProblemIDs(ctx, userID, title, description, startTime, endTime, durationMins, maxEntries, allowLateJoin, problemIDs) + if err != nil { + return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) + } + + return contestID, nil +} + +type ContestDetails struct { + Contest models.Contest + Problems []models.Problem + IsParticipant bool + SubmissionDeadline *time.Time + ProblemStatuses map[int32]string +} + +func (s *ContestService) GetContestByID(ctx context.Context, contestID int32, userID *int32) (*ContestDetails, error) { + op := "service.ContestService.GetContestByID" + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrContestNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + now := time.Now() + if contest.EndTime.Before(now) { + if userID == nil || *userID != contest.CreatorID { + return nil, ErrContestFinished + } + } + + problems, err := s.repo.Contest.GetProblemset(ctx, contestID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get problemset: %w", op, err) + } + + details := &ContestDetails{ + Contest: contest, + Problems: problems, + } + + if userID == nil { + return details, nil + } + + entry, err := s.repo.Entry.Get(ctx, contestID, *userID) + if errors.Is(err, pgx.ErrNoRows) { + return details, nil + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) + } + + details.IsParticipant = true + + _, deadline := CalculateSubmissionWindow(contest, entry) + if contest.StartTime.Before(now) { + details.SubmissionDeadline = &deadline + } + + statuses, err := s.repo.Submission.GetProblemStatuses(ctx, entry.ID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get problem statuses: %w", op, err) + } + details.ProblemStatuses = statuses + + return details, nil +} + +type ListContestsResult struct { + Contests []models.Contest + Total int +} + +func (s *ContestService) ListCreatedContests(ctx context.Context, creatorID int32, limit, offset int) (*ListContestsResult, error) { + op := "service.ContestService.ListCreatedContests" + + contests, total, err := s.repo.Contest.GetWithCreatorID(ctx, creatorID, limit, offset) + if err != nil { + return nil, fmt.Errorf("%s: failed to get created contests: %w", op, err) + } + + return &ListContestsResult{ + Contests: contests, + Total: total, + }, nil +} + +func (s *ContestService) ListAllContests(ctx context.Context, limit, offset int) (*ListContestsResult, error) { + op := "service.ContestService.ListAllContests" + + contests, total, err := s.repo.Contest.ListAll(ctx, limit, offset) + if err != nil { + return nil, fmt.Errorf("%s: failed to list all contests: %w", op, err) + } + + return &ListContestsResult{ + Contests: contests, + Total: total, + }, nil +} + +type LeaderboardResult struct { + Leaderboard []models.LeaderboardEntry + Total int +} + +func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limit, offset int) (*LeaderboardResult, error) { + op := "service.ContestService.GetLeaderboard" + + _, err := s.repo.Contest.GetByID(ctx, int32(contestID)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrContestNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + leaderboard, total, err := s.repo.Contest.GetLeaderboard(ctx, contestID, limit, offset) + if err != nil { + return nil, fmt.Errorf("%s: failed to get leaderboard: %w", op, err) + } + + return &LeaderboardResult{ + Leaderboard: leaderboard, + Total: total, + }, nil +} diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go new file mode 100644 index 0000000..1c55806 --- /dev/null +++ b/internal/app/service/entry.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/storage/repository" +) + +type EntryService struct { + repo *repository.Repository +} + +func NewEntryService(repo *repository.Repository) *EntryService { + return &EntryService{ + repo: repo, + } +} + +func (s *EntryService) CreateEntry(ctx context.Context, contestID int32, userID int32) error { + op := "service.EntryService.CreateEntry" + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrContestNotFound + } + if err != nil { + return fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + entriesCount, err := s.repo.Contest.GetEntriesCount(ctx, contestID) + if err != nil { + return fmt.Errorf("%s: failed to get entries count: %w", op, err) + } + + if contest.MaxEntries != 0 && entriesCount >= contest.MaxEntries { + return ErrMaxSlotsReached + } + + now := time.Now() + if contest.EndTime.Before(now) || (contest.StartTime.Before(now) && !contest.AllowLateJoin) { + return ErrApplicationTimeOver + } + + _, err = s.repo.Entry.Get(ctx, contestID, userID) + if err == nil { + return ErrEntryAlreadyExists + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("%s: failed to check existing entry: %w", op, err) + } + + _, err = s.repo.Entry.Create(ctx, contestID, userID) + if err != nil { + return fmt.Errorf("%s: failed to create entry: %w", op, err) + } + + return nil +} diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go new file mode 100644 index 0000000..42eea3a --- /dev/null +++ b/internal/app/service/errors.go @@ -0,0 +1,35 @@ +package service + +import "errors" + +var ( + // account + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserNotFound = errors.New("user not found") + ErrTokenGeneration = errors.New("failed to generate token") + ErrInvalidToken = errors.New("invalid or expired token") + + // entry + ErrContestFinished = errors.New("contest not found") + ErrContestNotFound = errors.New("contest not found") + ErrMaxSlotsReached = errors.New("max slots limit reached") + ErrApplicationTimeOver = errors.New("application time is over") + ErrEntryAlreadyExists = errors.New("user already has entry for this contest") + + // problem + ErrUserBanned = errors.New("you are banned from creating problems") + ErrProblemsLimitExceeded = errors.New("problems limit exceeded") + ErrContestsLimitExceeded = errors.New("contests limit exceeded") + ErrInvalidTimeLimit = errors.New("time_limit_ms must be between 500 and 10000") + ErrInvalidMemoryLimit = errors.New("memory_limit_mb must be between 16 and 512") + ErrContestNotStarted = errors.New("contest not started yet") + ErrNotProblemWriter = errors.New("you are not the problem writer") + + // submissions + ErrProblemNotFound = errors.New("problem not found") + ErrNoEntryForContest = errors.New("no entry for contest") + ErrSubmissionWindowClosed = errors.New("submission window is currently closed") + ErrSubmissionNotFound = errors.New("submission not found") + ErrInvalidCharcode = errors.New("problem's charcode couldn't be longer than 2 characters") +) diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go new file mode 100644 index 0000000..962addf --- /dev/null +++ b/internal/app/service/problem.go @@ -0,0 +1,199 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository" +) + +type ProblemService struct { + repo *repository.Repository +} + +func NewProblemService(repo *repository.Repository) *ProblemService { + return &ProblemService{ + repo: repo, + } +} + +func (s *ProblemService) CreateProblem(ctx context.Context, userID int32, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, testCases []models.TestCaseDTO) (int32, error) { + op := "service.ProblemService.CreateProblem" + + userRole, err := s.repo.User.GetRole(ctx, userID) + if err != nil { + return 0, fmt.Errorf("%s: failed to get user role: %w", op, err) + } + + if userRole.Name == models.RoleBanned { + return 0, ErrUserBanned + } + + if userRole.Name == models.RoleLimited { + problemsCount, err := s.repo.User.GetCreatedProblemsCount(ctx, userID) + if err != nil { + return 0, fmt.Errorf("%s: failed to get created problems count: %w", op, err) + } + + if problemsCount >= int(userRole.CreatedProblemsLimit) { + return 0, ErrProblemsLimitExceeded + } + } + + if timeLimitMS < 500 || timeLimitMS > 10000 { + return 0, ErrInvalidTimeLimit + } + + if memoryLimitMB < 16 || memoryLimitMB > 512 { + return 0, ErrInvalidMemoryLimit + } + + examplesCount := 0 + for i := range testCases { + if testCases[i].IsExample { + examplesCount++ + } + + if examplesCount > 3 && testCases[i].IsExample { + testCases[i].IsExample = false + } + } + + if checker == "" { + checker = "tokens" + } + + problemID, err := s.repo.Problem.CreateWithTCs(ctx, userID, title, statement, difficulty, timeLimitMS, memoryLimitMB, checker, testCases) + if err != nil { + return 0, fmt.Errorf("%s: failed to create problem: %w", op, err) + } + + return problemID, nil +} + +type ListProblemsResult struct { + Problems []models.Problem + Total int +} + +func (s *ProblemService) GetCreatedProblems(ctx context.Context, writerID int32, limit, offset int) (*ListProblemsResult, error) { + op := "service.ProblemService.GetCreatedProblems" + + problems, total, err := s.repo.Problem.GetWithWriterID(ctx, writerID, limit, offset) + if err != nil { + return nil, fmt.Errorf("%s: failed to get created problems: %w", op, err) + } + + return &ListProblemsResult{ + Problems: problems, + Total: total, + }, nil +} + +type ContestProblemDetails struct { + Problem models.Problem + Examples []models.TestCase + Status string + SubmissionWindow SubmissionWindow +} + +type SubmissionWindow struct { + Earliest time.Time + Deadline time.Time +} + +func (s *ProblemService) GetContestProblem(ctx context.Context, contestID int32, userID int32, charcode string) (*ContestProblemDetails, error) { + op := "service.ProblemService.GetContestProblem" + + if len(charcode) > 2 { + return nil, ErrInvalidCharcode + } + charcode = strings.ToUpper(charcode) + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrContestNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + now := time.Now() + if contest.StartTime.After(now) { + return nil, ErrContestNotStarted + } + + entry, err := s.repo.Entry.Get(ctx, contestID, userID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoEntryForContest + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) + } + + problem, err := s.repo.Problem.Get(ctx, contestID, charcode) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProblemNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get problem: %w", op, err) + } + + examples, err := s.repo.Problem.GetExampleCases(ctx, problem.ID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get example cases: %w", op, err) + } + + status, err := s.repo.Submission.GetProblemStatus(ctx, entry.ID, problem.ID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get problem status: %w", op, err) + } + + earliest, deadline := CalculateSubmissionWindow(contest, entry) + + return &ContestProblemDetails{ + Problem: problem, + Examples: examples, + Status: status, + SubmissionWindow: SubmissionWindow{ + Earliest: earliest, + Deadline: deadline, + }, + }, nil +} + +type ProblemDetails struct { + Problem models.Problem + Examples []models.TestCase +} + +func (s *ProblemService) GetProblemByID(ctx context.Context, problemID int32, userID int32) (*ProblemDetails, error) { + op := "service.ProblemService.GetProblemByID" + + problem, err := s.repo.Problem.GetByID(ctx, problemID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProblemNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get problem: %w", op, err) + } + + if problem.WriterID != userID { + return nil, ErrNotProblemWriter + } + + examples, err := s.repo.Problem.GetExampleCases(ctx, problem.ID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get example cases: %w", op, err) + } + + return &ProblemDetails{ + Problem: problem, + Examples: examples, + }, nil +} diff --git a/internal/app/service/service.go b/internal/app/service/service.go new file mode 100644 index 0000000..66ee670 --- /dev/null +++ b/internal/app/service/service.go @@ -0,0 +1,25 @@ +package service + +import ( + "github.com/voidcontests/api/internal/config" + "github.com/voidcontests/api/internal/storage/broker" + "github.com/voidcontests/api/internal/storage/repository" +) + +type Service struct { + Account *AccountService + Entry *EntryService + Submission *SubmissionService + Problem *ProblemService + Contest *ContestService +} + +func New(cfg *config.Config, repo *repository.Repository, broker broker.Broker) *Service { + return &Service{ + Account: NewAccountService(cfg, repo), + Entry: NewEntryService(repo), + Submission: NewSubmissionService(repo, broker), + Problem: NewProblemService(repo), + Contest: NewContestService(repo), + } +} diff --git a/internal/app/service/submission.go b/internal/app/service/submission.go new file mode 100644 index 0000000..f2c8b23 --- /dev/null +++ b/internal/app/service/submission.go @@ -0,0 +1,178 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/storage/broker" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/models/status" + "github.com/voidcontests/api/internal/storage/repository" +) + +type SubmissionService struct { + repo *repository.Repository + broker broker.Broker +} + +func NewSubmissionService(repo *repository.Repository, broker broker.Broker) *SubmissionService { + return &SubmissionService{ + repo: repo, + broker: broker, + } +} + +type CreateSubmissionResult struct { + Submission models.Submission +} + +func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int32, userID int32, charcode, code, language string) (*CreateSubmissionResult, error) { + op := "service.SubmissionService.CreateSubmission" + + if len(charcode) > 2 { + return nil, ErrInvalidCharcode + } + charcode = strings.ToUpper(charcode) + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrContestNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + entry, err := s.repo.Entry.Get(ctx, contestID, userID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoEntryForContest + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) + } + + now := time.Now() + earliest, deadline := CalculateSubmissionWindow(contest, entry) + if earliest.After(now) || deadline.Before(now) { + return nil, ErrSubmissionWindowClosed + } + + problem, err := s.repo.Problem.Get(ctx, contestID, charcode) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrProblemNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get problem: %w", op, err) + } + + submission, err := s.repo.Submission.Create(ctx, entry.ID, problem.ID, code, language) + if err != nil { + return nil, fmt.Errorf("%s: failed to create submission: %w", op, err) + } + + if err := s.broker.PublishSubmission(ctx, submission); err != nil { + return nil, fmt.Errorf("%s: failed to publish submission: %w", op, err) + } + + return &CreateSubmissionResult{ + Submission: submission, + }, nil +} + +type SubmissionDetails struct { + Submission models.Submission + TestingReport *models.TestingReport + FailedTest *models.TestCase +} + +func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID int32) (*SubmissionDetails, error) { + op := "service.SubmissionService.GetSubmissionByID" + + submission, err := s.repo.Submission.GetByID(ctx, submissionID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSubmissionNotFound + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get submission: %w", op, err) + } + + details := &SubmissionDetails{ + Submission: submission, + } + + if submission.Status != status.Success { + return details, nil + } + + testingReport, err := s.repo.Submission.GetTestingReport(ctx, submission.ID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get testing report: %w", op, err) + } + details.TestingReport = &testingReport + + if testingReport.FirstFailedTestID != nil { + failedTest, err := s.repo.Problem.GetTestCaseByID(ctx, *testingReport.FirstFailedTestID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get failed test case: %w", op, err) + } + details.FailedTest = &failedTest + } + + return details, nil +} + +type ListSubmissionsResult struct { + Submissions []models.Submission + Total int +} + +func (s *SubmissionService) ListSubmissions(ctx context.Context, contestID int32, userID int32, charcode string, limit, offset int) (*ListSubmissionsResult, error) { + op := "service.SubmissionService.ListSubmissions" + + if len(charcode) > 2 { + return nil, ErrInvalidCharcode + } + charcode = strings.ToUpper(charcode) + + entry, err := s.repo.Entry.Get(ctx, contestID, userID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoEntryForContest + } + if err != nil { + return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) + } + + submissions, total, err := s.repo.Submission.ListByProblem(ctx, entry.ID, charcode, limit, offset) + if err != nil { + return nil, fmt.Errorf("%s: failed to list submissions: %w", op, err) + } + + return &ListSubmissionsResult{ + Submissions: submissions, + Total: total, + }, nil +} + +func CalculateSubmissionWindow(contest models.Contest, entry models.Entry) (earliest time.Time, deadline time.Time) { + if contest.DurationMins == 0 { + return contest.StartTime, contest.EndTime + } + + earliest = entry.CreatedAt + if contest.StartTime.After(earliest) { + earliest = contest.StartTime + } + + personalDeadline := earliest.Add(time.Duration(contest.DurationMins) * time.Minute) + + if personalDeadline.Before(contest.EndTime) { + deadline = personalDeadline + } else { + deadline = contest.EndTime + } + + return earliest, deadline +} From 6a3cd5d008f21d0539e3aa0da397d809a647591f Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 02:15:28 +0400 Subject: [PATCH 02/56] chore: replace int32 to int --- internal/app/handler/contest.go | 4 +- internal/app/handler/dto/request/request.go | 6 +- internal/app/handler/dto/response/response.go | 62 +++++++++---------- internal/app/handler/entry.go | 2 +- internal/app/handler/problem.go | 6 +- internal/app/handler/submission.go | 6 +- internal/app/service/account.go | 4 +- internal/app/service/contest.go | 10 +-- internal/app/service/entry.go | 2 +- internal/app/service/problem.go | 8 +-- internal/app/service/submission.go | 6 +- internal/jwt/jwt.go | 6 +- internal/storage/models/models.go | 62 +++++++++---------- .../repository/postgres/contest/contest.go | 18 +++--- .../repository/postgres/entry/entry.go | 4 +- .../repository/postgres/problem/problem.go | 14 ++--- .../postgres/submission/submission.go | 16 ++--- .../storage/repository/postgres/user/user.go | 8 +-- internal/storage/repository/repository.go | 48 +++++++------- 19 files changed, 146 insertions(+), 146 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 72b1d81..444378c 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -49,12 +49,12 @@ func (h *Handler) GetContestByID(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - var userID *int32 + var userID *int if authenticated { userID = &claims.UserID } - details, err := h.service.Contest.GetContestByID(ctx, int32(contestID), userID) + details, err := h.service.Contest.GetContestByID(ctx, int(contestID), userID) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index c0bbd7a..c14b61d 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -19,11 +19,11 @@ type CreateSession struct { type CreateContestRequest struct { Title string `json:"title" required:"true"` Description string `json:"description"` - ProblemsIDs []int32 `json:"problems_ids" required:"true"` + ProblemsIDs []int `json:"problems_ids" required:"true"` StartTime time.Time `json:"start_time" required:"true"` EndTime time.Time `json:"end_time" required:"true"` - DurationMins int32 `json:"duration_mins" requried:"true"` - MaxEntries int32 `json:"max_entries"` + DurationMins int `json:"duration_mins" requried:"true"` + MaxEntries int `json:"max_entries"` AllowLateJoin bool `json:"allow_late_join"` } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index be5ab0c..f418c5d 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -18,7 +18,7 @@ type Meta struct { } type ID struct { - ID int32 `json:"id"` + ID int `json:"id"` } type Token struct { @@ -26,32 +26,32 @@ type Token struct { } type Account struct { - ID int32 `json:"id"` + ID int `json:"id"` Username string `json:"username"` Role Role `json:"role"` } type Role struct { Name string `json:"name"` - CreatedProblemsLimit int32 `json:"created_problems_limit"` - CreatedContestsLimit int32 `json:"created_contests_limit"` + CreatedProblemsLimit int `json:"created_problems_limit"` + CreatedContestsLimit int `json:"created_contests_limit"` } type User struct { - ID int32 `json:"id"` + ID int `json:"id"` Username string `json:"username"` } type ContestDetailed struct { - ID int32 `json:"id"` + ID int `json:"id"` Creator User `json:"creator"` Title string `json:"title"` Description string `json:"description"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` - DurationMins int32 `json:"duration_mins"` - MaxEntries int32 `json:"max_entries,omitempty"` - Participants int32 `json:"participants"` + DurationMins int `json:"duration_mins"` + MaxEntries int `json:"max_entries,omitempty"` + Participants int `json:"participants"` AllowLateJoin bool `json:"allow_late_join"` IsParticipant bool `json:"is_participant,omitempty"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` @@ -60,20 +60,20 @@ type ContestDetailed struct { } type ContestListItem struct { - ID int32 `json:"id"` + ID int `json:"id"` Creator User `json:"creator"` Title string `json:"title"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` - DurationMins int32 `json:"duration_mins"` - MaxEntries int32 `json:"max_entries,omitempty"` - Participants int32 `json:"participants"` + DurationMins int `json:"duration_mins"` + MaxEntries int `json:"max_entries,omitempty"` + Participants int `json:"participants"` CreatedAt time.Time `json:"created_at"` } type Submission struct { - ID int32 `json:"id"` - ProblemID int32 `json:"problem_id"` + ID int `json:"id"` + ProblemID int `json:"problem_id"` Status string `json:"status"` Verdict string `json:"verdict"` Code string `json:"code,omitempty"` @@ -83,9 +83,9 @@ type Submission struct { } type TestingReport struct { - ID int32 `json:"id"` - PassedTestsCount int32 `json:"passed_tests_count"` - TotalTestsCount int32 `json:"total_tests_count"` + ID int `json:"id"` + PassedTestsCount int `json:"passed_tests_count"` + TotalTestsCount int `json:"total_tests_count"` FailedTest *Test `json:"failed_test,omitempty"` Stderr string `json:"stderr"` CreatedAt time.Time `json:"created_at"` @@ -98,55 +98,55 @@ type Test struct { } type ContestProblemDetailed struct { - ID int32 `json:"id"` + ID int `json:"id"` Charcode string `json:"charcode"` - ContestID int32 `json:"contest_id"` + ContestID int `json:"contest_id"` Writer User `json:"writer"` Title string `json:"title"` Statement string `json:"statement"` Examples []TC `json:"examples,omitempty"` Difficulty string `json:"difficulty"` Status string `json:"status,omitempty"` - TimeLimitMS int32 `json:"time_limit_ms"` - MemoryLimitMB int32 `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` CreatedAt time.Time `json:"created_at"` } type ContestProblemListItem struct { - ID int32 `json:"id"` + ID int `json:"id"` Charcode string `json:"charcode"` Writer User `json:"writer"` Title string `json:"title"` Difficulty string `json:"difficulty"` Status string `json:"status,omitempty"` - TimeLimitMS int32 `json:"time_limit_ms"` - MemoryLimitMB int32 `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } type ProblemDetailed struct { - ID int32 `json:"id"` + ID int `json:"id"` Writer User `json:"writer"` Title string `json:"title"` Statement string `json:"statement"` Examples []TC `json:"examples,omitempty"` Difficulty string `json:"difficulty"` - TimeLimitMS int32 `json:"time_limit_ms"` - MemoryLimitMB int32 `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } type ProblemListItem struct { - ID int32 `json:"id"` + ID int `json:"id"` Writer User `json:"writer"` Title string `json:"title"` Difficulty string `json:"difficulty"` - TimeLimitMS int32 `json:"time_limit_ms"` - MemoryLimitMB int32 `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index b7180c3..8620b75 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -18,7 +18,7 @@ func (h *Handler) CreateEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - err := h.service.Entry.CreateEntry(ctx, int32(contestID), claims.UserID) + err := h.service.Entry.CreateEntry(ctx, int(contestID), claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 066842b..01645b7 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -105,7 +105,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { charcode := c.Param("charcode") - details, err := h.service.Problem.GetContestProblem(ctx, int32(contestID), claims.UserID, charcode) + details, err := h.service.Problem.GetContestProblem(ctx, int(contestID), claims.UserID, charcode) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): @@ -136,7 +136,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { pdetailed := response.ContestProblemDetailed{ ID: p.ID, Charcode: p.Charcode, - ContestID: int32(contestID), + ContestID: int(contestID), Title: p.Title, Statement: p.Statement, Examples: examples, @@ -169,7 +169,7 @@ func (h *Handler) GetProblemByID(c echo.Context) error { return Error(http.StatusBadRequest, "problem ID should be an integer") } - details, err := h.service.Problem.GetProblemByID(ctx, int32(problemID), claims.UserID) + details, err := h.service.Problem.GetProblemByID(ctx, int(problemID), claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrProblemNotFound): diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index 728470e..9559e3e 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -33,7 +33,7 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body") } - result, err := h.service.Submission.CreateSubmission(ctx, int32(contestID), claims.UserID, charcode, body.Code, body.Language) + result, err := h.service.Submission.CreateSubmission(ctx, int(contestID), claims.UserID, charcode, body.Code, body.Language) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): @@ -75,7 +75,7 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { return Error(http.StatusBadRequest, "submission ID should be an integer") } - details, err := h.service.Submission.GetSubmissionByID(ctx, int32(submissionID)) + details, err := h.service.Submission.GetSubmissionByID(ctx, int(submissionID)) if err != nil { if errors.Is(err, service.ErrSubmissionNotFound) { return Error(http.StatusNotFound, "submission not found") @@ -169,7 +169,7 @@ func (h *Handler) GetSubmissions(c echo.Context) error { offset = 0 } - result, err := h.service.Submission.ListSubmissions(ctx, int32(contestID), claims.UserID, charcode, limit, offset) + result, err := h.service.Submission.ListSubmissions(ctx, int(contestID), claims.UserID, charcode, limit, offset) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): diff --git a/internal/app/service/account.go b/internal/app/service/account.go index a31a05b..16319e5 100644 --- a/internal/app/service/account.go +++ b/internal/app/service/account.go @@ -25,7 +25,7 @@ func NewAccountService(cfg *config.Config, repo *repository.Repository) *Account } } -func (s *AccountService) CreateAccount(ctx context.Context, username, password string) (int32, error) { +func (s *AccountService) CreateAccount(ctx context.Context, username, password string) (int, error) { op := "service.AccountService.CreateAccount" exists, err := s.repo.User.Exists(ctx, username) @@ -73,7 +73,7 @@ type AccountInfo struct { Role models.Role } -func (s *AccountService) GetAccount(ctx context.Context, userID int32) (*AccountInfo, error) { +func (s *AccountService) GetAccount(ctx context.Context, userID int) (*AccountInfo, error) { op := "service.AccountService.GetAccount" user, err := s.repo.User.GetByID(ctx, userID) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 704046e..139b2ca 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -21,7 +21,7 @@ func NewContestService(repo *repository.Repository) *ContestService { } } -func (s *ContestService) CreateContest(ctx context.Context, userID int32, title, description string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool, problemIDs []int32) (int32, error) { +func (s *ContestService) CreateContest(ctx context.Context, userID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { op := "service.ContestService.CreateContest" userRole, err := s.repo.User.GetRole(ctx, userID) @@ -57,10 +57,10 @@ type ContestDetails struct { Problems []models.Problem IsParticipant bool SubmissionDeadline *time.Time - ProblemStatuses map[int32]string + ProblemStatuses map[int]string } -func (s *ContestService) GetContestByID(ctx context.Context, contestID int32, userID *int32) (*ContestDetails, error) { +func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID *int) (*ContestDetails, error) { op := "service.ContestService.GetContestByID" contest, err := s.repo.Contest.GetByID(ctx, contestID) @@ -121,7 +121,7 @@ type ListContestsResult struct { Total int } -func (s *ContestService) ListCreatedContests(ctx context.Context, creatorID int32, limit, offset int) (*ListContestsResult, error) { +func (s *ContestService) ListCreatedContests(ctx context.Context, creatorID int, limit, offset int) (*ListContestsResult, error) { op := "service.ContestService.ListCreatedContests" contests, total, err := s.repo.Contest.GetWithCreatorID(ctx, creatorID, limit, offset) @@ -157,7 +157,7 @@ type LeaderboardResult struct { func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limit, offset int) (*LeaderboardResult, error) { op := "service.ContestService.GetLeaderboard" - _, err := s.repo.Contest.GetByID(ctx, int32(contestID)) + _, err := s.repo.Contest.GetByID(ctx, int(contestID)) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrContestNotFound } diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index 1c55806..194c0cf 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -20,7 +20,7 @@ func NewEntryService(repo *repository.Repository) *EntryService { } } -func (s *EntryService) CreateEntry(ctx context.Context, contestID int32, userID int32) error { +func (s *EntryService) CreateEntry(ctx context.Context, contestID int, userID int) error { op := "service.EntryService.CreateEntry" contest, err := s.repo.Contest.GetByID(ctx, contestID) diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index 962addf..463f042 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -22,7 +22,7 @@ func NewProblemService(repo *repository.Repository) *ProblemService { } } -func (s *ProblemService) CreateProblem(ctx context.Context, userID int32, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, testCases []models.TestCaseDTO) (int32, error) { +func (s *ProblemService) CreateProblem(ctx context.Context, userID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, testCases []models.TestCaseDTO) (int, error) { op := "service.ProblemService.CreateProblem" userRole, err := s.repo.User.GetRole(ctx, userID) @@ -81,7 +81,7 @@ type ListProblemsResult struct { Total int } -func (s *ProblemService) GetCreatedProblems(ctx context.Context, writerID int32, limit, offset int) (*ListProblemsResult, error) { +func (s *ProblemService) GetCreatedProblems(ctx context.Context, writerID int, limit, offset int) (*ListProblemsResult, error) { op := "service.ProblemService.GetCreatedProblems" problems, total, err := s.repo.Problem.GetWithWriterID(ctx, writerID, limit, offset) @@ -107,7 +107,7 @@ type SubmissionWindow struct { Deadline time.Time } -func (s *ProblemService) GetContestProblem(ctx context.Context, contestID int32, userID int32, charcode string) (*ContestProblemDetails, error) { +func (s *ProblemService) GetContestProblem(ctx context.Context, contestID int, userID int, charcode string) (*ContestProblemDetails, error) { op := "service.ProblemService.GetContestProblem" if len(charcode) > 2 { @@ -172,7 +172,7 @@ type ProblemDetails struct { Examples []models.TestCase } -func (s *ProblemService) GetProblemByID(ctx context.Context, problemID int32, userID int32) (*ProblemDetails, error) { +func (s *ProblemService) GetProblemByID(ctx context.Context, problemID int, userID int) (*ProblemDetails, error) { op := "service.ProblemService.GetProblemByID" problem, err := s.repo.Problem.GetByID(ctx, problemID) diff --git a/internal/app/service/submission.go b/internal/app/service/submission.go index f2c8b23..a77e789 100644 --- a/internal/app/service/submission.go +++ b/internal/app/service/submission.go @@ -30,7 +30,7 @@ type CreateSubmissionResult struct { Submission models.Submission } -func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int32, userID int32, charcode, code, language string) (*CreateSubmissionResult, error) { +func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int, userID int, charcode, code, language string) (*CreateSubmissionResult, error) { op := "service.SubmissionService.CreateSubmission" if len(charcode) > 2 { @@ -88,7 +88,7 @@ type SubmissionDetails struct { FailedTest *models.TestCase } -func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID int32) (*SubmissionDetails, error) { +func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID int) (*SubmissionDetails, error) { op := "service.SubmissionService.GetSubmissionByID" submission, err := s.repo.Submission.GetByID(ctx, submissionID) @@ -129,7 +129,7 @@ type ListSubmissionsResult struct { Total int } -func (s *SubmissionService) ListSubmissions(ctx context.Context, contestID int32, userID int32, charcode string, limit, offset int) (*ListSubmissionsResult, error) { +func (s *SubmissionService) ListSubmissions(ctx context.Context, contestID int, userID int, charcode string, limit, offset int) (*ListSubmissionsResult, error) { op := "service.SubmissionService.ListSubmissions" if len(charcode) > 2 { diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go index 1529645..a3116e1 100644 --- a/internal/jwt/jwt.go +++ b/internal/jwt/jwt.go @@ -9,10 +9,10 @@ import ( type CustomClaims struct { jwt.RegisteredClaims - UserID int32 `json:"id"` + UserID int `json:"id"` } -func GenerateToken(id int32, secret string) (string, error) { +func GenerateToken(id int, secret string) (string, error) { claims := &CustomClaims{ jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().AddDate(100, 0, 0)), @@ -30,7 +30,7 @@ func GenerateToken(id int32, secret string) (string, error) { return signedToken, nil } -func Parse(token, secret string) (id int32, err error) { +func Parse(token, secret string) (id int, err error) { jsonwebtoken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index ccefcbf..ef423b9 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -10,55 +10,55 @@ const ( ) type User struct { - ID int32 `db:"id"` + ID int `db:"id"` Username string `db:"username"` PasswordHash string `db:"password_hash"` - RoleID int32 `db:"role_id"` + RoleID int `db:"role_id"` CreatedAt time.Time `db:"created_at"` } type Role struct { - ID int32 `db:"id"` + ID int `db:"id"` Name string `db:"name"` - CreatedProblemsLimit int32 `db:"created_problems_limit"` - CreatedContestsLimit int32 `db:"created_contests_limit"` + CreatedProblemsLimit int `db:"created_problems_limit"` + CreatedContestsLimit int `db:"created_contests_limit"` IsDefault bool `db:"is_default"` CreatedAt time.Time `db:"created_at"` } type Contest struct { - ID int32 `db:"id"` - CreatorID int32 `db:"creator_id"` + ID int `db:"id"` + CreatorID int `db:"creator_id"` CreatorUsername string `db:"creator_username"` Title string `db:"title"` Description string `db:"description"` StartTime time.Time `db:"start_time"` EndTime time.Time `db:"end_time"` - DurationMins int32 `db:"duration_mins"` - MaxEntries int32 `db:"max_entries"` + DurationMins int `db:"duration_mins"` + MaxEntries int `db:"max_entries"` AllowLateJoin bool `db:"allow_late_join"` - Participants int32 `db:"participants"` + Participants int `db:"participants"` CreatedAt time.Time `db:"created_at"` } type Problem struct { - ID int32 `db:"id"` + ID int `db:"id"` Charcode string `db:"charcode"` - WriterID int32 `db:"writer_id"` + WriterID int `db:"writer_id"` WriterUsername string `db:"writer_username"` Title string `db:"title"` Statement string `db:"statement"` Difficulty string `db:"difficulty"` - TimeLimitMS int32 `db:"time_limit_ms"` - MemoryLimitMB int32 `db:"memory_limit_mb"` + TimeLimitMS int `db:"time_limit_ms"` + MemoryLimitMB int `db:"memory_limit_mb"` Checker string `db:"checker"` CreatedAt time.Time `db:"created_at"` } type TestCase struct { - ID int32 `db:"id"` - ProblemID int32 `db:"problem_id"` - Ordinal int32 `db:"ordinal"` + ID int `db:"id"` + ProblemID int `db:"problem_id"` + Ordinal int `db:"ordinal"` Input string `db:"input"` Output string `db:"output"` IsExample bool `db:"is_example"` @@ -71,16 +71,16 @@ type TestCaseDTO struct { } type Entry struct { - ID int32 `db:"id"` - ContestID int32 `db:"contest_id"` - UserID int32 `db:"user_id"` + ID int `db:"id"` + ContestID int `db:"contest_id"` + UserID int `db:"user_id"` CreatedAt time.Time `db:"created_at"` } type Submission struct { - ID int32 `db:"id"` - EntryID int32 `db:"entry_id"` - ProblemID int32 `db:"problem_id"` + ID int `db:"id"` + EntryID int `db:"entry_id"` + ProblemID int `db:"problem_id"` Status string `db:"status"` Verdict string `db:"verdict"` Code string `db:"code"` @@ -89,25 +89,25 @@ type Submission struct { } type TestingReport struct { - ID int32 `db:"id"` - SubmissionID int32 `db:"submission_id"` - PassedTestsCount int32 `db:"passed_tests_count"` - TotalTestsCount int32 `db:"total_tests_count"` - FirstFailedTestID *int32 `db:"first_failed_test_id"` + ID int `db:"id"` + SubmissionID int `db:"submission_id"` + PassedTestsCount int `db:"passed_tests_count"` + TotalTestsCount int `db:"total_tests_count"` + FirstFailedTestID *int `db:"first_failed_test_id"` FirstFailedTestOutput *string `db:"first_failed_test_output"` Stderr string `db:"stderr"` CreatedAt time.Time `db:"created_at"` } type LeaderboardEntry struct { - UserID int32 `db:"user_id" json:"user_id"` + UserID int `db:"user_id" json:"user_id"` Username string `db:"username" json:"username"` Points int `db:"points" json:"points"` } type FailedTest struct { - ID int32 `db:"id"` - SubmissionID int32 `db:"submission_id"` + ID int `db:"id"` + SubmissionID int `db:"submission_id"` Input string `db:"input"` ExpectedOutput string `db:"expected_output"` ActualOutput string `db:"actual_output"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 329d58f..d1cd6a8 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -21,15 +21,15 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) Create(ctx context.Context, creatorID int32, title, description string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool) (int32, error) { - var id int32 +func (p *Postgres) Create(ctx context.Context, creatorID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool) (int, error) { + var id int query := `INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id` err := p.pool.QueryRow(ctx, query, creatorID, title, description, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&id) return id, err } -func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int32, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool, problemIDs []int32) (int32, error) { +func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if len(problemIDs) > len(charcodes) { return 0, fmt.Errorf("not enough charcodes for the number of problems") @@ -41,7 +41,7 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int32, ti } defer tx.Rollback(ctx) - var contestID int32 + var contestID int err = tx.QueryRow(ctx, ` INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) @@ -80,7 +80,7 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int32, ti return contestID, nil } -func (p *Postgres) GetByID(ctx context.Context, contestID int32) (models.Contest, error) { +func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, error) { var contest models.Contest query := `SELECT contests.*, users.username AS creator_username, COUNT(entries.id) AS participants FROM contests @@ -92,7 +92,7 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int32) (models.Contest return contest, err } -func (p *Postgres) GetProblemset(ctx context.Context, contestID int32) ([]models.Problem, error) { +func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { query := `SELECT cp.charcode, p.*, u.username AS writer_username FROM problems p JOIN contest_problems cp ON p.id = cp.problem_id @@ -172,7 +172,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests return contests, total, nil } -func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int32, limit, offset int) (contests []models.Contest, total int, err error) { +func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, offset int) (contests []models.Contest, total int, err error) { batch := &pgx.Batch{} batch.Queue(` SELECT contests.*, users.username AS creator_username, COUNT(entries.id) AS participants @@ -224,8 +224,8 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int32, limit, return contests, total, nil } -func (p *Postgres) GetEntriesCount(ctx context.Context, contestID int32) (int32, error) { - var count int32 +func (p *Postgres) GetEntriesCount(ctx context.Context, contestID int) (int, error) { + var count int err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM entries WHERE contest_id = $1`, contestID).Scan(&count) return count, err } diff --git a/internal/storage/repository/postgres/entry/entry.go b/internal/storage/repository/postgres/entry/entry.go index a00bc9f..b5e9288 100644 --- a/internal/storage/repository/postgres/entry/entry.go +++ b/internal/storage/repository/postgres/entry/entry.go @@ -15,7 +15,7 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) Create(ctx context.Context, contestID int32, userID int32) (int, error) { +func (p *Postgres) Create(ctx context.Context, contestID int, userID int) (int, error) { query := `INSERT INTO entries (contest_id, user_id) VALUES ($1, $2) RETURNING id` var id int @@ -26,7 +26,7 @@ func (p *Postgres) Create(ctx context.Context, contestID int32, userID int32) (i return id, nil } -func (p *Postgres) Get(ctx context.Context, contestID int32, userID int32) (models.Entry, error) { +func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) { query := `SELECT id, contest_id, user_id, created_at FROM entries WHERE contest_id = $1 AND user_id = $2` diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index c0a886c..be93419 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -17,14 +17,14 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int32, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int32, error) { +func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int, error) { tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return 0, fmt.Errorf("tx begin failed: %w", err) } defer tx.Rollback(ctx) - var problemID int32 + var problemID int err = tx.QueryRow(ctx, ` INSERT INTO problems (writer_id, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -64,7 +64,7 @@ func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int32, title, sta return problemID, nil } -func (p *Postgres) Get(ctx context.Context, contestID int32, charcode string) (models.Problem, error) { +func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { query := `SELECT p.*, cp.charcode, u.username AS writer_username FROM problems p JOIN contest_problems cp ON p.id = cp.problem_id @@ -83,7 +83,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int32, charcode string) (m return problem, err } -func (p *Postgres) GetByID(ctx context.Context, problemID int32) (models.Problem, error) { +func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, error) { query := `SELECT p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, p.memory_limit_mb, p.checker, p.created_at, @@ -104,7 +104,7 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int32) (models.Problem return problem, err } -func (p *Postgres) GetExampleCases(ctx context.Context, problemID int32) ([]models.TestCase, error) { +func (p *Postgres) GetExampleCases(ctx context.Context, problemID int) ([]models.TestCase, error) { query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE problem_id = $1 AND is_example = true` rows, err := p.pool.Query(ctx, query, problemID) @@ -125,7 +125,7 @@ func (p *Postgres) GetExampleCases(ctx context.Context, problemID int32) ([]mode return tcs, rows.Err() } -func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int32) (models.TestCase, error) { +func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models.TestCase, error) { query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE id = $1` var tc models.TestCase @@ -169,7 +169,7 @@ func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { return problems, rows.Err() } -func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int32, limit, offset int) (problems []models.Problem, total int, err error) { +func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, offset int) (problems []models.Problem, total int, err error) { batch := &pgx.Batch{} batch.Queue(` diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index 6a9f9df..213285a 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -21,7 +21,7 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) Create(ctx context.Context, entryID int32, problemID int32, code string, language string) (models.Submission, error) { +func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code string, language string) (models.Submission, error) { query := `INSERT INTO submissions (entry_id, problem_id, code, language) VALUES ($1, $2, $3, $4) RETURNING id, entry_id, problem_id, status, verdict, code, language, created_at` @@ -41,7 +41,7 @@ func (p *Postgres) Create(ctx context.Context, entryID int32, problemID int32, c return submission, err } -func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int32, problemID int32) (string, error) { +func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int, problemID int) (string, error) { query := ` SELECT CASE @@ -65,7 +65,7 @@ func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int32, problemI return "", nil } -func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int32) (map[int32]string, error) { +func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int) (map[int]string, error) { query := ` SELECT s.problem_id, @@ -85,10 +85,10 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int32) (map[i } defer rows.Close() - statuses := make(map[int32]string) + statuses := make(map[int]string) for rows.Next() { - var problemID int32 + var problemID int var status sql.NullString if err := rows.Scan(&problemID, &status); err != nil { @@ -109,7 +109,7 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int32) (map[i return statuses, nil } -func (p *Postgres) GetByID(ctx context.Context, submissionID int32) (models.Submission, error) { +func (p *Postgres) GetByID(ctx context.Context, submissionID int) (models.Submission, error) { query := `SELECT s.id, s.entry_id, s.problem_id, s.status, s.verdict, s.code, s.language, s.created_at FROM submissions s WHERE s.id = $1` @@ -128,7 +128,7 @@ func (p *Postgres) GetByID(ctx context.Context, submissionID int32) (models.Subm return s, err } -func (p *Postgres) ListByProblem(ctx context.Context, entryID int32, charcode string, limit int, offset int) (items []models.Submission, total int, err error) { +func (p *Postgres) ListByProblem(ctx context.Context, entryID int, charcode string, limit int, offset int) (items []models.Submission, total int, err error) { if limit < 0 { limit = defaultLimit } @@ -174,7 +174,7 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int32, charcode st return items, total, nil } -func (p *Postgres) GetTestingReport(ctx context.Context, submissionID int32) (models.TestingReport, error) { +func (p *Postgres) GetTestingReport(ctx context.Context, submissionID int) (models.TestingReport, error) { query := `SELECT id, submission_id, passed_tests_count, total_tests_count, first_failed_test_id, first_failed_test_output, stderr, created_at FROM testing_reports WHERE submission_id = $1` diff --git a/internal/storage/repository/postgres/user/user.go b/internal/storage/repository/postgres/user/user.go index 2ad2375..f7223f2 100644 --- a/internal/storage/repository/postgres/user/user.go +++ b/internal/storage/repository/postgres/user/user.go @@ -60,7 +60,7 @@ func (p *Postgres) Exists(ctx context.Context, username string) (bool, error) { return count > 0, nil } -func (p *Postgres) GetByID(ctx context.Context, id int32) (models.User, error) { +func (p *Postgres) GetByID(ctx context.Context, id int) (models.User, error) { var user models.User query := `SELECT id, username, password_hash, role_id, created_at FROM users WHERE id = $1` @@ -74,7 +74,7 @@ func (p *Postgres) GetByID(ctx context.Context, id int32) (models.User, error) { return user, err } -func (p *Postgres) GetRole(ctx context.Context, userID int32) (models.Role, error) { +func (p *Postgres) GetRole(ctx context.Context, userID int) (models.Role, error) { var role models.Role query := ` @@ -94,7 +94,7 @@ func (p *Postgres) GetRole(ctx context.Context, userID int32) (models.Role, erro return role, err } -func (p *Postgres) GetCreatedProblemsCount(ctx context.Context, userID int32) (int, error) { +func (p *Postgres) GetCreatedProblemsCount(ctx context.Context, userID int) (int, error) { var count int query := `SELECT COUNT(*) FROM problems WHERE writer_id = $1` @@ -102,7 +102,7 @@ func (p *Postgres) GetCreatedProblemsCount(ctx context.Context, userID int32) (i return count, err } -func (p *Postgres) GetCreatedContestsCount(ctx context.Context, userID int32) (int, error) { +func (p *Postgres) GetCreatedContestsCount(ctx context.Context, userID int) (int, error) { var count int query := `SELECT COUNT(*) FROM contests WHERE creator_id = $1` diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index c8eda6a..75a3730 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -35,44 +35,44 @@ type User interface { GetByCredentials(ctx context.Context, username string, passwordHash string) (models.User, error) Create(ctx context.Context, username string, passwordHash string) (models.User, error) Exists(ctx context.Context, username string) (bool, error) - GetByID(ctx context.Context, id int32) (models.User, error) - GetRole(ctx context.Context, userID int32) (models.Role, error) - GetCreatedProblemsCount(ctx context.Context, userID int32) (int, error) - GetCreatedContestsCount(ctx context.Context, userID int32) (int, error) + GetByID(ctx context.Context, id int) (models.User, error) + GetRole(ctx context.Context, userID int) (models.Role, error) + GetCreatedProblemsCount(ctx context.Context, userID int) (int, error) + GetCreatedContestsCount(ctx context.Context, userID int) (int, error) } type Contest interface { - Create(ctx context.Context, creatorID int32, title, description string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool) (int32, error) - CreateWithProblemIDs(ctx context.Context, creatorID int32, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int32, allowLateJoin bool, problemIDs []int32) (int32, error) - GetByID(ctx context.Context, contestID int32) (models.Contest, error) - GetProblemset(ctx context.Context, contestID int32) ([]models.Problem, error) + Create(ctx context.Context, creatorID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool) (int, error) + CreateWithProblemIDs(ctx context.Context, creatorID int, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) + GetByID(ctx context.Context, contestID int) (models.Contest, error) + GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) ListAll(ctx context.Context, limit int, offset int) (contests []models.Contest, total int, err error) - GetWithCreatorID(ctx context.Context, creatorID int32, limit, offset int) (contests []models.Contest, total int, err error) - GetEntriesCount(ctx context.Context, contestID int32) (int32, error) + GetWithCreatorID(ctx context.Context, creatorID int, limit, offset int) (contests []models.Contest, total int, err error) + GetEntriesCount(ctx context.Context, contestID int) (int, error) IsTitleOccupied(ctx context.Context, title string) (bool, error) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) } type Problem interface { - CreateWithTCs(ctx context.Context, writerID int32, title string, statement string, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int32, error) - Get(ctx context.Context, contestID int32, charcode string) (models.Problem, error) - GetByID(ctx context.Context, problemID int32) (models.Problem, error) - GetExampleCases(ctx context.Context, problemID int32) ([]models.TestCase, error) - GetTestCaseByID(ctx context.Context, testCaseID int32) (models.TestCase, error) + CreateWithTCs(ctx context.Context, writerID int, title string, statement string, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int, error) + Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) + GetByID(ctx context.Context, problemID int) (models.Problem, error) + GetExampleCases(ctx context.Context, problemID int) ([]models.TestCase, error) + GetTestCaseByID(ctx context.Context, testCaseID int) (models.TestCase, error) GetAll(ctx context.Context) ([]models.Problem, error) - GetWithWriterID(ctx context.Context, writerID int32, limit, offset int) (problems []models.Problem, total int, err error) + GetWithWriterID(ctx context.Context, writerID int, limit, offset int) (problems []models.Problem, total int, err error) } type Entry interface { - Create(ctx context.Context, contestID int32, userID int32) (int, error) - Get(ctx context.Context, contestID int32, userID int32) (models.Entry, error) + Create(ctx context.Context, contestID int, userID int) (int, error) + Get(ctx context.Context, contestID int, userID int) (models.Entry, error) } type Submission interface { - Create(ctx context.Context, entryID int32, problemID int32, code string, language string) (models.Submission, error) - GetProblemStatus(ctx context.Context, entryID int32, problemID int32) (string, error) - GetProblemStatuses(ctx context.Context, entryID int32) (map[int32]string, error) - GetByID(ctx context.Context, submissionID int32) (models.Submission, error) - ListByProblem(ctx context.Context, entryID int32, charcode string, limit int, offset int) (items []models.Submission, total int, err error) - GetTestingReport(ctx context.Context, submissionID int32) (models.TestingReport, error) + Create(ctx context.Context, entryID int, problemID int, code string, language string) (models.Submission, error) + GetProblemStatus(ctx context.Context, entryID int, problemID int) (string, error) + GetProblemStatuses(ctx context.Context, entryID int) (map[int]string, error) + GetByID(ctx context.Context, submissionID int) (models.Submission, error) + ListByProblem(ctx context.Context, entryID int, charcode string, limit int, offset int) (items []models.Submission, total int, err error) + GetTestingReport(ctx context.Context, submissionID int) (models.TestingReport, error) } From 89aa391d77112a040588e20acdd980147b0e7bae Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 02:19:59 +0400 Subject: [PATCH 03/56] chore: cleanup --- internal/app/handler/contest.go | 7 +------ internal/app/service/contest.go | 8 ++++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 444378c..643aac6 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -49,12 +49,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - var userID *int - if authenticated { - userID = &claims.UserID - } - - details, err := h.service.Contest.GetContestByID(ctx, int(contestID), userID) + details, err := h.service.Contest.GetContestByID(ctx, contestID, claims.UserID, authenticated) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 139b2ca..5aea3b9 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -60,7 +60,7 @@ type ContestDetails struct { ProblemStatuses map[int]string } -func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID *int) (*ContestDetails, error) { +func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID int, authenticated bool) (*ContestDetails, error) { op := "service.ContestService.GetContestByID" contest, err := s.repo.Contest.GetByID(ctx, contestID) @@ -73,7 +73,7 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user now := time.Now() if contest.EndTime.Before(now) { - if userID == nil || *userID != contest.CreatorID { + if !authenticated || userID != contest.CreatorID { return nil, ErrContestFinished } } @@ -88,11 +88,11 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user Problems: problems, } - if userID == nil { + if !authenticated { return details, nil } - entry, err := s.repo.Entry.Get(ctx, contestID, *userID) + entry, err := s.repo.Entry.Get(ctx, contestID, userID) if errors.Is(err, pgx.ErrNoRows) { return details, nil } From a317e9ab2cbd78b22d5b82b1d2f1dc007e2649b3 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 03:12:24 +0400 Subject: [PATCH 04/56] chore: use params struct to services --- internal/app/handler/contest.go | 12 +++++++- internal/app/handler/entry.go | 2 +- internal/app/handler/problem.go | 17 ++++++++--- internal/app/handler/submission.go | 12 ++++++-- internal/app/service/contest.go | 36 ++++++++++++++++++++---- internal/app/service/problem.go | 45 ++++++++++++++++++++++-------- internal/app/service/submission.go | 22 ++++++++++----- 7 files changed, 113 insertions(+), 33 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 643aac6..cfc17dc 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -22,7 +22,17 @@ func (h *Handler) CreateContest(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - id, err := h.service.Contest.CreateContest(ctx, claims.UserID, body.Title, body.Description, body.StartTime, body.EndTime, body.DurationMins, body.MaxEntries, body.AllowLateJoin, body.ProblemsIDs) + id, err := h.service.Contest.CreateContest(ctx, service.CreateContestParams{ + UserID: claims.UserID, + Title: body.Title, + Description: body.Description, + StartTime: body.StartTime, + EndTime: body.EndTime, + DurationMins: body.DurationMins, + MaxEntries: body.MaxEntries, + AllowLateJoin: body.AllowLateJoin, + ProblemIDs: body.ProblemsIDs, + }) if err != nil { switch { case errors.Is(err, service.ErrUserBanned): diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index 8620b75..d530c29 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -18,7 +18,7 @@ func (h *Handler) CreateEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - err := h.service.Entry.CreateEntry(ctx, int(contestID), claims.UserID) + err := h.service.Entry.CreateEntry(ctx, contestID, claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 01645b7..a959477 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -22,7 +22,16 @@ func (h *Handler) CreateProblem(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body: missing required fields") } - id, err := h.service.Problem.CreateProblem(ctx, claims.UserID, body.Title, body.Statement, body.Difficulty, body.TimeLimitMS, body.MemoryLimitMB, body.Checker, body.TestCases) + id, err := h.service.Problem.CreateProblem(ctx, service.CreateProblemParams{ + UserID: claims.UserID, + Title: body.Title, + Statement: body.Statement, + Difficulty: body.Difficulty, + TimeLimitMS: body.TimeLimitMS, + MemoryLimitMB: body.MemoryLimitMB, + Checker: body.Checker, + TestCases: body.TestCases, + }) if err != nil { switch { case errors.Is(err, service.ErrUserBanned): @@ -105,7 +114,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { charcode := c.Param("charcode") - details, err := h.service.Problem.GetContestProblem(ctx, int(contestID), claims.UserID, charcode) + details, err := h.service.Problem.GetContestProblem(ctx, contestID, claims.UserID, charcode) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): @@ -136,7 +145,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { pdetailed := response.ContestProblemDetailed{ ID: p.ID, Charcode: p.Charcode, - ContestID: int(contestID), + ContestID: contestID, Title: p.Title, Statement: p.Statement, Examples: examples, @@ -169,7 +178,7 @@ func (h *Handler) GetProblemByID(c echo.Context) error { return Error(http.StatusBadRequest, "problem ID should be an integer") } - details, err := h.service.Problem.GetProblemByID(ctx, int(problemID), claims.UserID) + details, err := h.service.Problem.GetProblemByID(ctx, problemID, claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrProblemNotFound): diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index 9559e3e..a09c5a9 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -33,7 +33,13 @@ func (h *Handler) CreateSubmission(c echo.Context) error { return Error(http.StatusBadRequest, "invalid body") } - result, err := h.service.Submission.CreateSubmission(ctx, int(contestID), claims.UserID, charcode, body.Code, body.Language) + result, err := h.service.Submission.CreateSubmission(ctx, service.CreateSubmissionParams{ + ContestID: contestID, + UserID: claims.UserID, + Charcode: charcode, + Code: body.Code, + Language: body.Language, + }) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): @@ -75,7 +81,7 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { return Error(http.StatusBadRequest, "submission ID should be an integer") } - details, err := h.service.Submission.GetSubmissionByID(ctx, int(submissionID)) + details, err := h.service.Submission.GetSubmissionByID(ctx, submissionID) if err != nil { if errors.Is(err, service.ErrSubmissionNotFound) { return Error(http.StatusNotFound, "submission not found") @@ -169,7 +175,7 @@ func (h *Handler) GetSubmissions(c echo.Context) error { offset = 0 } - result, err := h.service.Submission.ListSubmissions(ctx, int(contestID), claims.UserID, charcode, limit, offset) + result, err := h.service.Submission.ListSubmissions(ctx, contestID, claims.UserID, charcode, limit, offset) if err != nil { switch { case errors.Is(err, service.ErrInvalidCharcode): diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 5aea3b9..cb7fca6 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -21,10 +21,23 @@ func NewContestService(repo *repository.Repository) *ContestService { } } -func (s *ContestService) CreateContest(ctx context.Context, userID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { +// CreateContestParams contains parameters for creating a contest +type CreateContestParams struct { + UserID int + Title string + Description string + StartTime time.Time + EndTime time.Time + DurationMins int + MaxEntries int + AllowLateJoin bool + ProblemIDs []int +} + +func (s *ContestService) CreateContest(ctx context.Context, params CreateContestParams) (int, error) { op := "service.ContestService.CreateContest" - userRole, err := s.repo.User.GetRole(ctx, userID) + userRole, err := s.repo.User.GetRole(ctx, params.UserID) if err != nil { return 0, fmt.Errorf("%s: failed to get user role: %w", op, err) } @@ -34,17 +47,28 @@ func (s *ContestService) CreateContest(ctx context.Context, userID int, title, d } if userRole.Name == models.RoleLimited { - contestsCount, err := s.repo.User.GetCreatedContestsCount(ctx, userID) + contestsCount, err := s.repo.User.GetCreatedContestsCount(ctx, params.UserID) if err != nil { return 0, fmt.Errorf("%s: failed to get created contests count: %w", op, err) } - if contestsCount >= int(userRole.CreatedContestsLimit) { + if contestsCount >= userRole.CreatedContestsLimit { return 0, ErrContestsLimitExceeded } } - contestID, err := s.repo.Contest.CreateWithProblemIDs(ctx, userID, title, description, startTime, endTime, durationMins, maxEntries, allowLateJoin, problemIDs) + contestID, err := s.repo.Contest.CreateWithProblemIDs( + ctx, + params.UserID, + params.Title, + params.Description, + params.StartTime, + params.EndTime, + params.DurationMins, + params.MaxEntries, + params.AllowLateJoin, + params.ProblemIDs, + ) if err != nil { return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) } @@ -157,7 +181,7 @@ type LeaderboardResult struct { func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limit, offset int) (*LeaderboardResult, error) { op := "service.ContestService.GetLeaderboard" - _, err := s.repo.Contest.GetByID(ctx, int(contestID)) + _, err := s.repo.Contest.GetByID(ctx, contestID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrContestNotFound } diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index 463f042..cb6ec72 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -22,10 +22,22 @@ func NewProblemService(repo *repository.Repository) *ProblemService { } } -func (s *ProblemService) CreateProblem(ctx context.Context, userID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, testCases []models.TestCaseDTO) (int, error) { +// CreateProblemParams contains parameters for creating a problem +type CreateProblemParams struct { + UserID int + Title string + Statement string + Difficulty string + TimeLimitMS int + MemoryLimitMB int + Checker string + TestCases []models.TestCaseDTO +} + +func (s *ProblemService) CreateProblem(ctx context.Context, params CreateProblemParams) (int, error) { op := "service.ProblemService.CreateProblem" - userRole, err := s.repo.User.GetRole(ctx, userID) + userRole, err := s.repo.User.GetRole(ctx, params.UserID) if err != nil { return 0, fmt.Errorf("%s: failed to get user role: %w", op, err) } @@ -35,40 +47,51 @@ func (s *ProblemService) CreateProblem(ctx context.Context, userID int, title, s } if userRole.Name == models.RoleLimited { - problemsCount, err := s.repo.User.GetCreatedProblemsCount(ctx, userID) + problemsCount, err := s.repo.User.GetCreatedProblemsCount(ctx, params.UserID) if err != nil { return 0, fmt.Errorf("%s: failed to get created problems count: %w", op, err) } - if problemsCount >= int(userRole.CreatedProblemsLimit) { + if problemsCount >= userRole.CreatedProblemsLimit { return 0, ErrProblemsLimitExceeded } } - if timeLimitMS < 500 || timeLimitMS > 10000 { + if params.TimeLimitMS < 500 || params.TimeLimitMS > 10000 { return 0, ErrInvalidTimeLimit } - if memoryLimitMB < 16 || memoryLimitMB > 512 { + if params.MemoryLimitMB < 16 || params.MemoryLimitMB > 512 { return 0, ErrInvalidMemoryLimit } examplesCount := 0 - for i := range testCases { - if testCases[i].IsExample { + for i := range params.TestCases { + if params.TestCases[i].IsExample { examplesCount++ } - if examplesCount > 3 && testCases[i].IsExample { - testCases[i].IsExample = false + if examplesCount > 3 && params.TestCases[i].IsExample { + params.TestCases[i].IsExample = false } } + checker := params.Checker if checker == "" { checker = "tokens" } - problemID, err := s.repo.Problem.CreateWithTCs(ctx, userID, title, statement, difficulty, timeLimitMS, memoryLimitMB, checker, testCases) + problemID, err := s.repo.Problem.CreateWithTCs( + ctx, + params.UserID, + params.Title, + params.Statement, + params.Difficulty, + params.TimeLimitMS, + params.MemoryLimitMB, + checker, + params.TestCases, + ) if err != nil { return 0, fmt.Errorf("%s: failed to create problem: %w", op, err) } diff --git a/internal/app/service/submission.go b/internal/app/service/submission.go index a77e789..dd1212b 100644 --- a/internal/app/service/submission.go +++ b/internal/app/service/submission.go @@ -26,19 +26,27 @@ func NewSubmissionService(repo *repository.Repository, broker broker.Broker) *Su } } +type CreateSubmissionParams struct { + ContestID int + UserID int + Charcode string + Code string + Language string +} + type CreateSubmissionResult struct { Submission models.Submission } -func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int, userID int, charcode, code, language string) (*CreateSubmissionResult, error) { +func (s *SubmissionService) CreateSubmission(ctx context.Context, params CreateSubmissionParams) (*CreateSubmissionResult, error) { op := "service.SubmissionService.CreateSubmission" - if len(charcode) > 2 { + if len(params.Charcode) > 2 { return nil, ErrInvalidCharcode } - charcode = strings.ToUpper(charcode) + charcode := strings.ToUpper(params.Charcode) - contest, err := s.repo.Contest.GetByID(ctx, contestID) + contest, err := s.repo.Contest.GetByID(ctx, params.ContestID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrContestNotFound } @@ -46,7 +54,7 @@ func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int, return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) } - entry, err := s.repo.Entry.Get(ctx, contestID, userID) + entry, err := s.repo.Entry.Get(ctx, params.ContestID, params.UserID) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNoEntryForContest } @@ -60,7 +68,7 @@ func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int, return nil, ErrSubmissionWindowClosed } - problem, err := s.repo.Problem.Get(ctx, contestID, charcode) + problem, err := s.repo.Problem.Get(ctx, params.ContestID, charcode) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrProblemNotFound } @@ -68,7 +76,7 @@ func (s *SubmissionService) CreateSubmission(ctx context.Context, contestID int, return nil, fmt.Errorf("%s: failed to get problem: %w", op, err) } - submission, err := s.repo.Submission.Create(ctx, entry.ID, problem.ID, code, language) + submission, err := s.repo.Submission.Create(ctx, entry.ID, problem.ID, params.Code, params.Language) if err != nil { return nil, fmt.Errorf("%s: failed to create submission: %w", op, err) } From 69fa08f163b1cd5e6306b02d247fd6cae6c607d8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 04:10:15 +0400 Subject: [PATCH 05/56] chore: remove select * from sql queries --- .../repository/postgres/contest/contest.go | 88 +++++++++++-------- .../repository/postgres/problem/problem.go | 34 ++++--- 2 files changed, 71 insertions(+), 51 deletions(-) diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index d1cd6a8..2984394 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -23,8 +23,9 @@ func New(pool *pgxpool.Pool) *Postgres { func (p *Postgres) Create(ctx context.Context, creatorID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool) (int, error) { var id int - query := `INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id` + query := ` +INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id` err := p.pool.QueryRow(ctx, query, creatorID, title, description, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&id) return id, err } @@ -43,10 +44,8 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, titl var contestID int err = tx.QueryRow(ctx, ` - INSERT INTO contests - (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id +INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, creatorID, title, desc, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&contestID) if err != nil { return 0, fmt.Errorf("insert contest failed: %w", err) @@ -54,10 +53,8 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, titl batch := &pgx.Batch{} for i, pid := range problemIDs { - batch.Queue(` - INSERT INTO contest_problems (contest_id, problem_id, charcode) - VALUES ($1, $2, $3) - `, contestID, pid, string(charcodes[i])) + batch.Queue(`INSERT INTO contest_problems (contest_id, problem_id, charcode) VALUES ($1, $2, $3)`, + contestID, pid, string(charcodes[i])) } br := tx.SendBatch(ctx, batch) @@ -82,22 +79,31 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, titl func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, error) { var contest models.Contest - query := `SELECT contests.*, users.username AS creator_username, COUNT(entries.id) AS participants - FROM contests - JOIN users ON users.id = contests.creator_id - LEFT JOIN entries ON entries.contest_id = contests.id - WHERE contests.id = $1 - GROUP BY contests.id, users.username` - err := p.pool.QueryRow(ctx, query, contestID).Scan(&contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) + query := ` +SELECT + c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, + c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants +FROM contests c +JOIN users u ON u.id = c.creator_id +LEFT JOIN entries e ON e.contest_id = c.id +WHERE c.id = $1 +GROUP BY c.id, u.username` + err := p.pool.QueryRow(ctx, query, contestID).Scan( + &contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, + &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, + &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) return contest, err } func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { - query := `SELECT cp.charcode, p.*, u.username AS writer_username - FROM problems p - JOIN contest_problems cp ON p.id = cp.problem_id - JOIN users u ON u.id = p.writer_id - WHERE cp.contest_id = $1 ORDER BY charcode ASC` + query := ` +SELECT + p.id, cp.charcode, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, + p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username +FROM problems p +JOIN contest_problems cp ON p.id = cp.problem_id +JOIN users u ON u.id = p.writer_id +WHERE cp.contest_id = $1 ORDER BY charcode ASC` rows, err := p.pool.Query(ctx, query, contestID) if err != nil { @@ -108,7 +114,7 @@ func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.P var problems []models.Problem for rows.Next() { var problem models.Problem - if err := rows.Scan(&problem.Charcode, &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.WriterUsername); err != nil { + if err := rows.Scan(&problem.ID, &problem.Charcode, &problem.WriterID, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.WriterUsername); err != nil { return nil, err } problems = append(problems, problem) @@ -123,14 +129,16 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests batch := &pgx.Batch{} batch.Queue(` - SELECT contests.*, users.username AS creator_username, COUNT(entries.id) AS participants - FROM contests - JOIN users ON users.id = contests.creator_id - LEFT JOIN entries ON entries.contest_id = contests.id - WHERE contests.end_time >= now() - GROUP BY contests.id, users.username - ORDER BY contests.id ASC - LIMIT $1 OFFSET $2 +SELECT + c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, + c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(u.id) AS participants +FROM contests c +JOIN users u ON u.id = c.creator_id +LEFT JOIN entries e ON e.contest_id = c.id +WHERE c.end_time >= now() +GROUP BY c.id, u.username +ORDER BY c.id ASC +LIMIT $1 OFFSET $2 `, limit, offset) batch.Queue(`SELECT COUNT(*) FROM contests WHERE contests.end_time >= now()`) @@ -175,14 +183,16 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, offset int) (contests []models.Contest, total int, err error) { batch := &pgx.Batch{} batch.Queue(` - SELECT contests.*, users.username AS creator_username, COUNT(entries.id) AS participants - FROM contests - JOIN users ON users.id = contests.creator_id - LEFT JOIN entries ON entries.contest_id = contests.id - WHERE contests.creator_id = $1 - GROUP BY contests.id, users.username - ORDER BY contests.id ASC - LIMIT $2 OFFSET $3 +SELECT + c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, + c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants +FROM contests c +JOIN users u ON u.id = c.creator_id +LEFT JOIN entries e ON e.contest_id = c.id +WHERE c.creator_id = $1 +GROUP BY c.id, u.username +ORDER BY c.id ASC +LIMIT $2 OFFSET $3 `, creatorID, limit, offset) batch.Queue(`SELECT COUNT(*) FROM contests WHERE creator_id = $1`, creatorID) diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index be93419..947617d 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -65,11 +65,14 @@ func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int, title, state } func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { - query := `SELECT p.*, cp.charcode, u.username AS writer_username - FROM problems p - JOIN contest_problems cp ON p.id = cp.problem_id - JOIN users u ON u.id = p.writer_id - WHERE cp.contest_id = $1 AND cp.charcode = $2` + query := ` +SELECT + p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, + p.memory_limit_mb, p.checker, p.created_at, cp.charcode, u.username AS writer_username +FROM problems p +JOIN contest_problems cp ON p.id = cp.problem_id +JOIN users u ON u.id = p.writer_id +WHERE cp.contest_id = $1 AND cp.charcode = $2` row := p.pool.QueryRow(ctx, query, contestID, charcode) @@ -146,7 +149,12 @@ func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models. } func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { - query := `SELECT problems.*, users.username AS writer_username FROM problems JOIN users ON users.id = problems.writer_id` + query := ` +SELECT + p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, + p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username +FROM problems p +JOIN users u ON u.id = p.writer_id` rows, err := p.pool.Query(ctx, query) if err != nil { @@ -173,12 +181,14 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, off batch := &pgx.Batch{} batch.Queue(` - SELECT problems.*, users.username AS writer_username - FROM problems - JOIN users ON users.id = problems.writer_id - WHERE writer_id = $1 - ORDER BY problems.id ASC - LIMIT $2 OFFSET $3 +SELECT + p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, + p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username +FROM problems p +JOIN users u ON u.id = p.writer_id +WHERE writer_id = $1 +ORDER BY p.id ASC +LIMIT $2 OFFSET $3 `, writerID, limit, offset) batch.Queue(` From 6a3eea7ccdacc33d190799e63f6f33af54280a0c Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 2 Nov 2025 23:53:53 +0400 Subject: [PATCH 06/56] chore: add migrations back, add ton package --- go.mod | 12 +-- go.sum | 24 ++--- migrations/000001_init.down.sql | 12 +++ migrations/000001_init.up.sql | 110 +++++++++++++++++++++++ pkg/ton/ton.go | 155 ++++++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 migrations/000001_init.down.sql create mode 100644 migrations/000001_init.up.sql create mode 100644 pkg/ton/ton.go diff --git a/go.mod b/go.mod index 2b15c11..b4104af 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,11 @@ require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.3 github.com/redis/go-redis/v9 v9.12.1 + github.com/xssnick/tonutils-go v1.15.5 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -27,11 +29,11 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/crypto v0.42.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.8.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect diff --git a/go.sum b/go.sum index 7a7ae01..1103747 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -58,18 +60,20 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +github.com/xssnick/tonutils-go v1.15.5 h1:yAcHnDaY5QW0aIQE47lT0PuDhhHYE+N+NyZssdPKR0s= +github.com/xssnick/tonutils-go v1.15.5/go.mod h1:3/B8mS5IWLTd1xbGbFbzRem55oz/Q86HG884bVsTqZ8= +golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql new file mode 100644 index 0000000..a970dd7 --- /dev/null +++ b/migrations/000001_init.down.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS testing_reports; +DROP TABLE IF EXISTS submissions; +DROP TABLE IF EXISTS entries; +DROP TABLE IF EXISTS contest_problems; +DROP TABLE IF EXISTS test_cases; +DROP TABLE IF EXISTS problems; +DROP TABLE IF EXISTS wallets; +DROP TABLE IF EXISTS contests; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS roles; + +DROP TYPE IF EXISTS contest_prize_type; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql new file mode 100644 index 0000000..52e3f8d --- /dev/null +++ b/migrations/000001_init.up.sql @@ -0,0 +1,110 @@ +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(20) UNIQUE NOT NULL, + created_problems_limit INTEGER NOT NULL, + created_contests_limit INTEGER NOT NULL, + is_default BOOLEAN DEFAULT false NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +INSERT INTO roles (name, created_problems_limit, created_contests_limit, is_default) VALUES + ('admin', -1, -1, false), + ('unlimited', -1, -1, false), + ('limited', 10, 2, true), + ('banned', 0, 0, false); + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TYPE contest_prize_type AS ENUM ( + 'no_prize', + 'paid_entry', + 'sponsored' +); + +CREATE TABLE contests ( + id SERIAL PRIMARY KEY, + creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(64) NOT NULL, + description VARCHAR(300) DEFAULT '' NOT NULL, + prize_type contest_prize_type NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), + max_entries INTEGER DEFAULT 0 NOT NULL CHECK (max_entries >= 0), + allow_late_join BOOLEAN DEFAULT true NOT NULL, + wallet_id INTEGER REFERENCES wallets(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE wallets ( + id SERIAL PRIMARY KEY, + address VARCHAR(100) NOT NULL, + mnemonic TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE problems ( + id SERIAL PRIMARY KEY, + writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(64) NOT NULL, + statement TEXT NOT NULL, + difficulty VARCHAR(10) NOT NULL CHECK (difficulty IN ('easy', 'mid', 'hard')), + time_limit_ms INTEGER DEFAULT 2000 NOT NULL CHECK (time_limit_ms >= 0), + memory_limit_mb INTEGER DEFAULT 128 NOT NULL CHECK (memory_limit_mb >= 0), + checker VARCHAR(10) NOT NULL DEFAULT 'tokens', + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE test_cases ( + id SERIAL PRIMARY KEY, + problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + input TEXT NOT NULL, + output TEXT NOT NULL, + is_example BOOLEAN DEFAULT false NOT NULL, + UNIQUE (problem_id, ordinal) +); + +CREATE TABLE contest_problems ( + contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, + problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, + charcode VARCHAR(2) NOT NULL, + PRIMARY KEY (contest_id, problem_id), + UNIQUE (contest_id, charcode) +); + +CREATE TABLE entries ( + id SERIAL PRIMARY KEY, + contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT now() NOT NULL, + UNIQUE (contest_id, user_id) +); + +CREATE TABLE submissions ( + id SERIAL PRIMARY KEY, + entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + verdict VARCHAR(30) NOT NULL DEFAULT 'not_judged', + code TEXT NOT NULL, + language VARCHAR(20) NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + +CREATE TABLE testing_reports ( + id SERIAL PRIMARY KEY, + submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE, + passed_tests_count INTEGER DEFAULT 0 NOT NULL CHECK (passed_tests_count >= 0), + total_tests_count INTEGER DEFAULT 0 NOT NULL CHECK (total_tests_count >= 0), + first_failed_test_id INTEGER REFERENCES test_cases(id), + first_failed_test_output TEXT DEFAULT '', + stderr TEXT DEFAULT '' NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go new file mode 100644 index 0000000..995ad17 --- /dev/null +++ b/pkg/ton/ton.go @@ -0,0 +1,155 @@ +package ton + +import ( + "context" + "fmt" + "strings" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/liteclient" + "github.com/xssnick/tonutils-go/tlb" + tonutils "github.com/xssnick/tonutils-go/ton" + "github.com/xssnick/tonutils-go/ton/wallet" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +type Client struct { + api tonutils.APIClientWrapped +} + +func NewClient(ctx context.Context) (*Client, error) { + client := liteclient.NewConnectionPool() + err := client.AddConnectionsFromConfigUrl(ctx, "https://ton.org/testnet-global.config.json") + if err != nil { + return nil, err + } + + unsafeAPI := tonutils.NewAPIClient(client, tonutils.ProofCheckPolicyUnsafe) + + block, err := unsafeAPI.GetMasterchainInfo(ctx) + if err != nil { + return nil, err + } + + api := tonutils.NewAPIClient(client, tonutils.ProofCheckPolicySecure).WithRetry() + api.SetTrustedBlock(block) + + return &Client{ + api: api, + }, nil +} + +type Wallet struct { + Address *address.Address + Mnemonic []string + Instance *wallet.Wallet +} + +func (c *Client) CreateWallet() (*Wallet, error) { + return c.WalletWithSeed(strings.Join(wallet.NewSeed(), " ")) +} + +func (c *Client) WalletWithSeed(mnemonic string) (*Wallet, error) { + words := strings.Split(mnemonic, " ") + w, err := wallet.FromSeedWithOptions(c.api, words, wallet.V4R2) + if err != nil { + return nil, fmt.Errorf("failed to create wallet from seed: %w", err) + } + + return &Wallet{ + Address: w.WalletAddress(), + Mnemonic: words, + Instance: w, + }, nil +} + +func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint64, error) { + block, err := c.api.CurrentMasterchainInfo(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get masterchain info: %w", err) + } + + account, err := c.api.GetAccount(ctx, block, address) + if err != nil { + return 0, fmt.Errorf("failed to get account: %w", err) + } + + if !account.IsActive { + return 0, nil + } + + return account.State.Balance.Nano().Uint64(), nil +} + +func (w *Wallet) TransferTo(ctx context.Context, recipient *address.Address, amount tlb.Coins, comment string) (tx string, err error) { + var body *cell.Cell + if comment != "" { + var err error + body, err = wallet.CreateCommentCell(comment) + if err != nil { + return "", fmt.Errorf("failed to create comment cell: %w", err) + } + } + + transaction, _, err := w.Instance.SendWaitTransaction(ctx, &wallet.Message{ + Mode: wallet.PayGasSeparately + wallet.IgnoreErrors, + InternalMessage: &tlb.InternalMessage{ + IHRDisabled: true, + Bounce: false, + DstAddr: recipient, + Amount: amount, + Body: body, + }, + }) + + if err != nil { + return "", fmt.Errorf("failed to send transaction: %w", err) + } + + return fmt.Sprintf("%x", transaction.Hash), nil +} + +func FromNano(nano uint64) string { + return tlb.FromNanoTONU(nano).String() +} + +func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *address.Address, amount tlb.Coins) bool { + block, err := c.api.CurrentMasterchainInfo(ctx) + if err != nil { + return false + } + + account, err := c.api.GetAccount(ctx, block, to) + if err != nil { + return false + } + + if !account.IsActive { + return false + } + + txList, err := c.api.ListTransactions(ctx, to, 100, account.LastTxLT, account.LastTxHash) + if err != nil { + return false + } + + for _, tx := range txList { + if tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal { + continue + } + + inMsg := tx.IO.In.AsInternal() + if inMsg == nil { + continue + } + + if inMsg.SrcAddr.Equals(from) { + // checks if in tx transferred at least `amount` + if inMsg.Amount.Nano().Cmp(amount.Nano()) >= 0 { + return true + } + } + } + + return false +} From f0cbec85fce7c7bd37e4e1e78413f79802bd7862 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 15:28:15 +0400 Subject: [PATCH 07/56] chore: Implement types for contests with prizes --- internal/app/handler/contest.go | 6 +- internal/app/handler/dto/request/request.go | 7 +- internal/app/handler/dto/response/response.go | 63 ++++++------- internal/app/handler/handler.go | 5 +- internal/app/router/router.go | 5 +- internal/app/service/account.go | 10 +- internal/app/service/contest.go | 82 +++++++++++++--- internal/app/service/service.go | 6 +- internal/pkg/app/app.go | 11 ++- internal/storage/models/models.go | 70 +++++++------- .../repository/postgres/contest/contest.go | 93 +++++++++++++++---- internal/storage/repository/repository.go | 5 +- migrations/000001_init.down.sql | 4 +- migrations/000001_init.up.sql | 22 +++-- 14 files changed, 266 insertions(+), 123 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index cfc17dc..79bdf72 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -26,6 +26,7 @@ func (h *Handler) CreateContest(c echo.Context) error { UserID: claims.UserID, Title: body.Title, Description: body.Description, + AwardType: body.AwardType, StartTime: body.StartTime, EndTime: body.EndTime, DurationMins: body.DurationMins, @@ -77,20 +78,21 @@ func (h *Handler) GetContestByID(c echo.Context) error { ID: contest.ID, Title: contest.Title, Description: contest.Description, - Problems: make([]response.ContestProblemListItem, n, n), Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, }, + Problems: make([]response.ContestProblemListItem, n, n), Participants: contest.Participants, StartTime: contest.StartTime, EndTime: contest.EndTime, DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, AllowLateJoin: contest.AllowLateJoin, - CreatedAt: contest.CreatedAt, IsParticipant: details.IsParticipant, SubmissionDeadline: details.SubmissionDeadline, + PrizePot: details.PrizePot, + CreatedAt: contest.CreatedAt, } for i := range n { diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index c14b61d..ac3025b 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -19,11 +19,12 @@ type CreateSession struct { type CreateContestRequest struct { Title string `json:"title" required:"true"` Description string `json:"description"` - ProblemsIDs []int `json:"problems_ids" required:"true"` + AwardType string `json:"award_type"` + ProblemsIDs []int `json:"problems_ids" required:"true"` StartTime time.Time `json:"start_time" required:"true"` EndTime time.Time `json:"end_time" required:"true"` - DurationMins int `json:"duration_mins" requried:"true"` - MaxEntries int `json:"max_entries"` + DurationMins int `json:"duration_mins" requried:"true"` + MaxEntries int `json:"max_entries"` AllowLateJoin bool `json:"allow_late_join"` } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index f418c5d..7623c3c 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -26,54 +26,55 @@ type Token struct { } type Account struct { - ID int `json:"id"` + ID int `json:"id"` Username string `json:"username"` Role Role `json:"role"` } type Role struct { Name string `json:"name"` - CreatedProblemsLimit int `json:"created_problems_limit"` - CreatedContestsLimit int `json:"created_contests_limit"` + CreatedProblemsLimit int `json:"created_problems_limit"` + CreatedContestsLimit int `json:"created_contests_limit"` } type User struct { - ID int `json:"id"` + ID int `json:"id"` Username string `json:"username"` } type ContestDetailed struct { - ID int `json:"id"` - Creator User `json:"creator"` + ID int `json:"id"` Title string `json:"title"` Description string `json:"description"` + Creator User `json:"creator"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` - DurationMins int `json:"duration_mins"` - MaxEntries int `json:"max_entries,omitempty"` - Participants int `json:"participants"` + DurationMins int `json:"duration_mins"` + MaxEntries int `json:"max_entries,omitempty"` + Participants int `json:"participants"` AllowLateJoin bool `json:"allow_late_join"` IsParticipant bool `json:"is_participant,omitempty"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` Problems []ContestProblemListItem `json:"problems"` + PrizePot uint64 `json:"prize_pot,omitempty"` // in TON nanos (1 TON = 1,000,000,000 nanos) CreatedAt time.Time `json:"created_at"` } type ContestListItem struct { - ID int `json:"id"` + ID int `json:"id"` Creator User `json:"creator"` Title string `json:"title"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` - DurationMins int `json:"duration_mins"` - MaxEntries int `json:"max_entries,omitempty"` - Participants int `json:"participants"` + DurationMins int `json:"duration_mins"` + MaxEntries int `json:"max_entries,omitempty"` + Participants int `json:"participants"` CreatedAt time.Time `json:"created_at"` } type Submission struct { - ID int `json:"id"` - ProblemID int `json:"problem_id"` + ID int `json:"id"` + ProblemID int `json:"problem_id"` Status string `json:"status"` Verdict string `json:"verdict"` Code string `json:"code,omitempty"` @@ -83,9 +84,9 @@ type Submission struct { } type TestingReport struct { - ID int `json:"id"` - PassedTestsCount int `json:"passed_tests_count"` - TotalTestsCount int `json:"total_tests_count"` + ID int `json:"id"` + PassedTestsCount int `json:"passed_tests_count"` + TotalTestsCount int `json:"total_tests_count"` FailedTest *Test `json:"failed_test,omitempty"` Stderr string `json:"stderr"` CreatedAt time.Time `json:"created_at"` @@ -98,55 +99,55 @@ type Test struct { } type ContestProblemDetailed struct { - ID int `json:"id"` + ID int `json:"id"` Charcode string `json:"charcode"` - ContestID int `json:"contest_id"` + ContestID int `json:"contest_id"` Writer User `json:"writer"` Title string `json:"title"` Statement string `json:"statement"` Examples []TC `json:"examples,omitempty"` Difficulty string `json:"difficulty"` Status string `json:"status,omitempty"` - TimeLimitMS int `json:"time_limit_ms"` - MemoryLimitMB int `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` CreatedAt time.Time `json:"created_at"` } type ContestProblemListItem struct { - ID int `json:"id"` + ID int `json:"id"` Charcode string `json:"charcode"` Writer User `json:"writer"` Title string `json:"title"` Difficulty string `json:"difficulty"` Status string `json:"status,omitempty"` - TimeLimitMS int `json:"time_limit_ms"` - MemoryLimitMB int `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } type ProblemDetailed struct { - ID int `json:"id"` + ID int `json:"id"` Writer User `json:"writer"` Title string `json:"title"` Statement string `json:"statement"` Examples []TC `json:"examples,omitempty"` Difficulty string `json:"difficulty"` - TimeLimitMS int `json:"time_limit_ms"` - MemoryLimitMB int `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } type ProblemListItem struct { - ID int `json:"id"` + ID int `json:"id"` Writer User `json:"writer"` Title string `json:"title"` Difficulty string `json:"difficulty"` - TimeLimitMS int `json:"time_limit_ms"` - MemoryLimitMB int `json:"memory_limit_mb"` + TimeLimitMS int `json:"time_limit_ms"` + MemoryLimitMB int `json:"memory_limit_mb"` Checker string `json:"checker"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index 8546c77..3853d5f 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -10,6 +10,7 @@ import ( "github.com/voidcontests/api/internal/jwt" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" ) type Handler struct { @@ -19,12 +20,12 @@ type Handler struct { service *service.Service } -func New(c *config.Config, r *repository.Repository, b broker.Broker) *Handler { +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Handler { return &Handler{ config: c, repo: r, broker: b, - service: service.New(c, r, b), + service: service.New(&c.Security, r, b, tc), } } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 61f47eb..a73d547 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -15,6 +15,7 @@ import ( "github.com/voidcontests/api/pkg/ratelimit" "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/requestlog" + "github.com/voidcontests/api/pkg/ton" ) type Router struct { @@ -22,8 +23,8 @@ type Router struct { handler *handler.Handler } -func New(c *config.Config, r *repository.Repository, b broker.Broker) *Router { - h := handler.New(c, r, b) +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Router { + h := handler.New(c, r, b, tc) return &Router{config: c, handler: h} } diff --git a/internal/app/service/account.go b/internal/app/service/account.go index 16319e5..727488e 100644 --- a/internal/app/service/account.go +++ b/internal/app/service/account.go @@ -14,11 +14,11 @@ import ( ) type AccountService struct { - config *config.Config + config *config.Security repo *repository.Repository } -func NewAccountService(cfg *config.Config, repo *repository.Repository) *AccountService { +func NewAccountService(cfg *config.Security, repo *repository.Repository) *AccountService { return &AccountService{ config: cfg, repo: repo, @@ -37,7 +37,7 @@ func (s *AccountService) CreateAccount(ctx context.Context, username, password s return 0, ErrUserAlreadyExists } - passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Security.Salt)) + passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Salt)) user, err := s.repo.User.Create(ctx, username, passwordHash) if err != nil { @@ -50,7 +50,7 @@ func (s *AccountService) CreateAccount(ctx context.Context, username, password s func (s *AccountService) CreateSession(ctx context.Context, username, password string) (string, error) { op := "service.AccountService.CreateSession" - passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Security.Salt)) + passwordHash := hasher.Sha256String([]byte(password), []byte(s.config.Salt)) user, err := s.repo.User.GetByCredentials(ctx, username, passwordHash) if errors.Is(err, pgx.ErrNoRows) { @@ -60,7 +60,7 @@ func (s *AccountService) CreateSession(ctx context.Context, username, password s return "", fmt.Errorf("%s: failed to get user by credentials: %w", op, err) } - token, err := jwt.GenerateToken(user.ID, s.config.Security.SignatureKey) + token, err := jwt.GenerateToken(user.ID, s.config.SignatureKey) if err != nil { return "", fmt.Errorf("%s: %w: %v", op, ErrTokenGeneration, err) } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index cb7fca6..38c34ff 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -4,20 +4,25 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" + "github.com/xssnick/tonutils-go/address" ) type ContestService struct { repo *repository.Repository + ton *ton.Client } -func NewContestService(repo *repository.Repository) *ContestService { +func NewContestService(repo *repository.Repository, tc *ton.Client) *ContestService { return &ContestService{ repo: repo, + ton: tc, } } @@ -26,6 +31,7 @@ type CreateContestParams struct { UserID int Title string Description string + AwardType string StartTime time.Time EndTime time.Time DurationMins int @@ -57,18 +63,48 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest } } - contestID, err := s.repo.Contest.CreateWithProblemIDs( - ctx, - params.UserID, - params.Title, - params.Description, - params.StartTime, - params.EndTime, - params.DurationMins, - params.MaxEntries, - params.AllowLateJoin, - params.ProblemIDs, - ) + // NOTE: if award type is not `paid_entry` or `sponsored` - use `no_prize` by default + var contestID int + if params.AwardType == "paid_entry" || params.AwardType == "sponsored" { + w, err := s.ton.CreateWallet() + if err != nil { + return 0, err + } + + address := w.Address.String() + mnemonic := strings.Join(w.Mnemonic, " ") + + contestID, err = s.repo.Contest.CreateWithWallet( + ctx, + params.UserID, + params.Title, + params.Description, + params.AwardType, + params.StartTime, + params.EndTime, + params.DurationMins, + params.MaxEntries, + params.AllowLateJoin, + params.ProblemIDs, + address, + mnemonic, + ) + } else { + contestID, err = s.repo.Contest.Create( + ctx, + params.UserID, + params.Title, + params.Description, + "no_award", + params.StartTime, + params.EndTime, + params.DurationMins, + params.MaxEntries, + params.AllowLateJoin, + params.ProblemIDs, + ) + } + if err != nil { return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) } @@ -82,6 +118,7 @@ type ContestDetails struct { IsParticipant bool SubmissionDeadline *time.Time ProblemStatuses map[int]string + PrizePot uint64 } func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID int, authenticated bool) (*ContestDetails, error) { @@ -112,6 +149,25 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user Problems: problems, } + // TODO: can additionally check for contest.award_type + if contest.WalletID != nil { + wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get wallet: %w", op, err) + } + + addr, err := address.ParseAddr(wallet.Address) + if err != nil { + return nil, fmt.Errorf("%s: failed to parse wallet address: %w", op, err) + } + + details.PrizePot, err = s.ton.GetBalance(ctx, addr) + if err != nil { + // TODO: maybe on this error, just return balance = 0 (?) + return nil, fmt.Errorf("%s: failed to get wallet balance: %w", op, err) + } + } + if !authenticated { return details, nil } diff --git a/internal/app/service/service.go b/internal/app/service/service.go index 66ee670..d5d2779 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -4,6 +4,7 @@ import ( "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" ) type Service struct { @@ -14,12 +15,13 @@ type Service struct { Contest *ContestService } -func New(cfg *config.Config, repo *repository.Repository, broker broker.Broker) *Service { +func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client) *Service { return &Service{ + // TODO: pass only salt and signature key, not entire config Account: NewAccountService(cfg, repo), Entry: NewEntryService(repo), Submission: NewSubmissionService(repo, broker), Problem: NewProblemService(repo), - Contest: NewContestService(repo), + Contest: NewContestService(repo, tc), } } diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index 97bd03d..ba9e5f1 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -19,6 +19,7 @@ import ( "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/internal/storage/repository/postgres" "github.com/voidcontests/api/internal/version" + "github.com/voidcontests/api/pkg/ton" ) type App struct { @@ -74,7 +75,15 @@ func (a *App) Run() { repo := repository.New(db) brok := broker.New(rc) - r := router.New(a.config, repo, brok) + tc, err := ton.NewClient(ctx) + if err != nil { + slog.Error("ton: could not establish connection", sl.Err(err)) + return + } + + slog.Info("ton: ok") + + r := router.New(a.config, repo, brok, tc) server := &http.Server{ Addr: a.config.Server.Address, diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index ef423b9..6fcde16 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -10,55 +10,63 @@ const ( ) type User struct { - ID int `db:"id"` + ID int `db:"id"` Username string `db:"username"` PasswordHash string `db:"password_hash"` - RoleID int `db:"role_id"` + RoleID int `db:"role_id"` CreatedAt time.Time `db:"created_at"` } type Role struct { - ID int `db:"id"` + ID int `db:"id"` Name string `db:"name"` - CreatedProblemsLimit int `db:"created_problems_limit"` - CreatedContestsLimit int `db:"created_contests_limit"` + CreatedProblemsLimit int `db:"created_problems_limit"` + CreatedContestsLimit int `db:"created_contests_limit"` IsDefault bool `db:"is_default"` CreatedAt time.Time `db:"created_at"` } type Contest struct { - ID int `db:"id"` - CreatorID int `db:"creator_id"` + ID int `db:"id"` + CreatorID int `db:"creator_id"` CreatorUsername string `db:"creator_username"` Title string `db:"title"` Description string `db:"description"` StartTime time.Time `db:"start_time"` EndTime time.Time `db:"end_time"` - DurationMins int `db:"duration_mins"` - MaxEntries int `db:"max_entries"` + DurationMins int `db:"duration_mins"` + MaxEntries int `db:"max_entries"` AllowLateJoin bool `db:"allow_late_join"` - Participants int `db:"participants"` + Participants int `db:"participants"` + WalletID *int `db:"wallet_id"` CreatedAt time.Time `db:"created_at"` } +type Wallet struct { + ID int `db:"id"` + Address string `db:"address"` + Mnemonic string `db:"mnemonic"` + CreatedAt time.Time `db:"created_at"` +} + type Problem struct { - ID int `db:"id"` + ID int `db:"id"` Charcode string `db:"charcode"` - WriterID int `db:"writer_id"` + WriterID int `db:"writer_id"` WriterUsername string `db:"writer_username"` Title string `db:"title"` Statement string `db:"statement"` Difficulty string `db:"difficulty"` - TimeLimitMS int `db:"time_limit_ms"` - MemoryLimitMB int `db:"memory_limit_mb"` + TimeLimitMS int `db:"time_limit_ms"` + MemoryLimitMB int `db:"memory_limit_mb"` Checker string `db:"checker"` CreatedAt time.Time `db:"created_at"` } type TestCase struct { - ID int `db:"id"` - ProblemID int `db:"problem_id"` - Ordinal int `db:"ordinal"` + ID int `db:"id"` + ProblemID int `db:"problem_id"` + Ordinal int `db:"ordinal"` Input string `db:"input"` Output string `db:"output"` IsExample bool `db:"is_example"` @@ -71,16 +79,16 @@ type TestCaseDTO struct { } type Entry struct { - ID int `db:"id"` - ContestID int `db:"contest_id"` - UserID int `db:"user_id"` + ID int `db:"id"` + ContestID int `db:"contest_id"` + UserID int `db:"user_id"` CreatedAt time.Time `db:"created_at"` } type Submission struct { - ID int `db:"id"` - EntryID int `db:"entry_id"` - ProblemID int `db:"problem_id"` + ID int `db:"id"` + EntryID int `db:"entry_id"` + ProblemID int `db:"problem_id"` Status string `db:"status"` Verdict string `db:"verdict"` Code string `db:"code"` @@ -89,25 +97,25 @@ type Submission struct { } type TestingReport struct { - ID int `db:"id"` - SubmissionID int `db:"submission_id"` - PassedTestsCount int `db:"passed_tests_count"` - TotalTestsCount int `db:"total_tests_count"` - FirstFailedTestID *int `db:"first_failed_test_id"` + ID int `db:"id"` + SubmissionID int `db:"submission_id"` + PassedTestsCount int `db:"passed_tests_count"` + TotalTestsCount int `db:"total_tests_count"` + FirstFailedTestID *int `db:"first_failed_test_id"` FirstFailedTestOutput *string `db:"first_failed_test_output"` Stderr string `db:"stderr"` CreatedAt time.Time `db:"created_at"` } type LeaderboardEntry struct { - UserID int `db:"user_id" json:"user_id"` + UserID int `db:"user_id" json:"user_id"` Username string `db:"username" json:"username"` Points int `db:"points" json:"points"` } type FailedTest struct { - ID int `db:"id"` - SubmissionID int `db:"submission_id"` + ID int `db:"id"` + SubmissionID int `db:"submission_id"` Input string `db:"input"` ExpectedOutput string `db:"expected_output"` ActualOutput string `db:"actual_output"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 2984394..8edad88 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -21,16 +21,55 @@ func New(pool *pgxpool.Pool) *Postgres { return &Postgres{pool} } -func (p *Postgres) Create(ctx context.Context, creatorID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool) (int, error) { - var id int - query := ` -INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id` - err := p.pool.QueryRow(ctx, query, creatorID, title, description, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&id) - return id, err +func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { + charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + if len(problemIDs) > len(charcodes) { + return 0, fmt.Errorf("not enough charcodes for the number of problems") + } + + tx, err := p.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + var contestID int + err = tx.QueryRow(ctx, ` +INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id + `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&contestID) + if err != nil { + return 0, fmt.Errorf("insert contest failed: %w", err) + } + + batch := &pgx.Batch{} + for i, pid := range problemIDs { + batch.Queue(`INSERT INTO contest_problems (contest_id, problem_id, charcode) VALUES ($1, $2, $3)`, + contestID, pid, string(charcodes[i])) + } + + br := tx.SendBatch(ctx, batch) + + for i := 0; i < len(problemIDs); i++ { + if _, err := br.Exec(); err != nil { + br.Close() + return 0, fmt.Errorf("insert contest_problem %d failed: %w", i, err) + } + } + + if err := br.Close(); err != nil { + return 0, fmt.Errorf("batch close failed: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit failed: %w", err) + } + + return contestID, nil } -func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { +// TODO: extract contest creation into separate function, probably with tx manager +func (p *Postgres) CreateWithWallet(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletAddress, walletMnemonic string) (int, error) { charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if len(problemIDs) > len(charcodes) { return 0, fmt.Errorf("not enough charcodes for the number of problems") @@ -42,11 +81,20 @@ func (p *Postgres) CreateWithProblemIDs(ctx context.Context, creatorID int, titl } defer tx.Rollback(ctx) + var walletID int + err = tx.QueryRow(ctx, ` +INSERT INTO wallets (address, mnemonic) +VALUES ($1, $2) RETURNING id + `, walletAddress, walletMnemonic).Scan(&walletID) + if err != nil { + return 0, fmt.Errorf("insert wallet failed: %w", err) + } + var contestID int err = tx.QueryRow(ctx, ` -INSERT INTO contests (creator_id, title, description, start_time, end_time, duration_mins, max_entries, allow_late_join) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id - `, creatorID, title, desc, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&contestID) +INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id + `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin, walletID).Scan(&contestID) if err != nil { return 0, fmt.Errorf("insert contest failed: %w", err) } @@ -82,7 +130,7 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, query := ` SELECT c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants + c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id @@ -91,10 +139,21 @@ GROUP BY c.id, u.username` err := p.pool.QueryRow(ctx, query, contestID).Scan( &contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, - &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) + &contest.WalletID, &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) return contest, err } +func (p *Postgres) GetWallet(ctx context.Context, walletID int) (models.Wallet, error) { + var wallet models.Wallet + query := ` +SELECT + w.id, w.address, w.mnemonic, w.created_at +FROM wallets w +WHERE w.id = $1` + err := p.pool.QueryRow(ctx, query, walletID).Scan(&wallet.ID, &wallet.Address, &wallet.Mnemonic, &wallet.CreatedAt) + return wallet, err +} + func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { query := ` SELECT @@ -131,7 +190,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests batch.Queue(` SELECT c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(u.id) AS participants + c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(u.id) AS participants FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id @@ -157,7 +216,7 @@ LIMIT $1 OFFSET $2 if err := rows.Scan( &c.ID, &c.CreatorID, &c.Title, &c.Description, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.CreatedAt, &c.CreatorUsername, &c.Participants, ); err != nil { rows.Close() @@ -185,7 +244,7 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch.Queue(` SELECT c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants + c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id @@ -211,7 +270,7 @@ LIMIT $2 OFFSET $3 if err := rows.Scan( &c.ID, &c.CreatorID, &c.Title, &c.Description, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.CreatedAt, &c.CreatorUsername, &c.Participants, ); err != nil { rows.Close() diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 75a3730..2695489 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -42,8 +42,8 @@ type User interface { } type Contest interface { - Create(ctx context.Context, creatorID int, title, description string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool) (int, error) - CreateWithProblemIDs(ctx context.Context, creatorID int, title, desc string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) + Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) + CreateWithWallet(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletAddress, walletMnemonic string) (int, error) GetByID(ctx context.Context, contestID int) (models.Contest, error) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) ListAll(ctx context.Context, limit int, offset int) (contests []models.Contest, total int, err error) @@ -51,6 +51,7 @@ type Contest interface { GetEntriesCount(ctx context.Context, contestID int) (int, error) IsTitleOccupied(ctx context.Context, title string) (bool, error) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) + GetWallet(ctx context.Context, walletID int) (models.Wallet, error) } type Problem interface { diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql index a970dd7..699c8c6 100644 --- a/migrations/000001_init.down.sql +++ b/migrations/000001_init.down.sql @@ -4,9 +4,9 @@ DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS contest_problems; DROP TABLE IF EXISTS test_cases; DROP TABLE IF EXISTS problems; -DROP TABLE IF EXISTS wallets; DROP TABLE IF EXISTS contests; +DROP TABLE IF EXISTS wallets; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS roles; -DROP TYPE IF EXISTS contest_prize_type; +DROP TYPE IF EXISTS contest_award_type; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index 52e3f8d..e2241dc 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -21,18 +21,27 @@ CREATE TABLE users ( created_at TIMESTAMP DEFAULT now() NOT NULL ); -CREATE TYPE contest_prize_type AS ENUM ( - 'no_prize', +CREATE TYPE contest_award_type AS ENUM ( + 'no_award', 'paid_entry', 'sponsored' ); +-- TODO: add unique index on `contests.wallet_id` +-- TODO: encrypt the mnemonic before saving +CREATE TABLE wallets ( + id SERIAL PRIMARY KEY, + address VARCHAR(100) NOT NULL, + mnemonic TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + CREATE TABLE contests ( id SERIAL PRIMARY KEY, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(64) NOT NULL, description VARCHAR(300) DEFAULT '' NOT NULL, - prize_type contest_prize_type NOT NULL, + award_type contest_award_type NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), @@ -42,13 +51,6 @@ CREATE TABLE contests ( created_at TIMESTAMP DEFAULT now() NOT NULL ); -CREATE TABLE wallets ( - id SERIAL PRIMARY KEY, - address VARCHAR(100) NOT NULL, - mnemonic TEXT NOT NULL, - created_at TIMESTAMP DEFAULT now() NOT NULL -); - CREATE TABLE problems ( id SERIAL PRIMARY KEY, writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, From d6d17d0129ec613d4ea56716adec4129851a70b2 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 18:21:20 +0400 Subject: [PATCH 08/56] chore: Introduce tx manager --- internal/app/service/contest.go | 54 +++++---- .../repository/postgres/contest/contest.go | 106 ++++-------------- .../repository/postgres/entry/entry.go | 12 +- .../postgres/submission/submission.go | 20 ++-- .../storage/repository/postgres/txmanager.go | 49 ++++++++ .../storage/repository/postgres/user/user.go | 22 ++-- .../repository/postgres/wallet/wallet.go | 27 +++++ internal/storage/repository/repository.go | 33 +++++- 8 files changed, 192 insertions(+), 131 deletions(-) create mode 100644 internal/storage/repository/postgres/txmanager.go create mode 100644 internal/storage/repository/postgres/wallet/wallet.go diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 38c34ff..1ed067f 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -74,21 +74,37 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest address := w.Address.String() mnemonic := strings.Join(w.Mnemonic, " ") - contestID, err = s.repo.Contest.CreateWithWallet( - ctx, - params.UserID, - params.Title, - params.Description, - params.AwardType, - params.StartTime, - params.EndTime, - params.DurationMins, - params.MaxEntries, - params.AllowLateJoin, - params.ProblemIDs, - address, - mnemonic, - ) + err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + repo := repository.NewTxRepository(tx) + + walletID, err := repo.Wallet.Create(ctx, address, mnemonic) + if err != nil { + return fmt.Errorf("create wallet: %w", err) + } + + contestID, err = repo.Contest.Create( + ctx, + params.UserID, + params.Title, + params.Description, + params.AwardType, + params.StartTime, + params.EndTime, + params.DurationMins, + params.MaxEntries, + params.AllowLateJoin, + params.ProblemIDs, + &walletID, + ) + if err != nil { + return fmt.Errorf("create contest: %w", err) + } + + return nil + }) + if err != nil { + return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) + } } else { contestID, err = s.repo.Contest.Create( ctx, @@ -102,11 +118,11 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest params.MaxEntries, params.AllowLateJoin, params.ProblemIDs, + nil, ) - } - - if err != nil { - return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) + if err != nil { + return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) + } } return contestID, nil diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 8edad88..82cb2b7 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -7,94 +7,38 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" ) const defaultLimit = 20 type Postgres struct { - pool *pgxpool.Pool + conn postgres.Transactor } -func New(pool *pgxpool.Pool) *Postgres { - return &Postgres{pool} +func New(txr postgres.Transactor) *Postgres { + return &Postgres{conn: txr} } -func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) { +func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) { charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - if len(problemIDs) > len(charcodes) { - return 0, fmt.Errorf("not enough charcodes for the number of problems") - } - - tx, err := p.pool.Begin(ctx) - if err != nil { - return 0, fmt.Errorf("begin transaction: %w", err) - } - defer tx.Rollback(ctx) var contestID int - err = tx.QueryRow(ctx, ` + var err error + + if walletID != nil { + err = p.conn.QueryRow(ctx, ` +INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id + `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin, walletID).Scan(&contestID) + } else { + err = p.conn.QueryRow(ctx, ` INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&contestID) - if err != nil { - return 0, fmt.Errorf("insert contest failed: %w", err) - } - - batch := &pgx.Batch{} - for i, pid := range problemIDs { - batch.Queue(`INSERT INTO contest_problems (contest_id, problem_id, charcode) VALUES ($1, $2, $3)`, - contestID, pid, string(charcodes[i])) - } - - br := tx.SendBatch(ctx, batch) - - for i := 0; i < len(problemIDs); i++ { - if _, err := br.Exec(); err != nil { - br.Close() - return 0, fmt.Errorf("insert contest_problem %d failed: %w", i, err) - } } - if err := br.Close(); err != nil { - return 0, fmt.Errorf("batch close failed: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return 0, fmt.Errorf("commit failed: %w", err) - } - - return contestID, nil -} - -// TODO: extract contest creation into separate function, probably with tx manager -func (p *Postgres) CreateWithWallet(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletAddress, walletMnemonic string) (int, error) { - charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - if len(problemIDs) > len(charcodes) { - return 0, fmt.Errorf("not enough charcodes for the number of problems") - } - - tx, err := p.pool.Begin(ctx) - if err != nil { - return 0, fmt.Errorf("begin transaction: %w", err) - } - defer tx.Rollback(ctx) - - var walletID int - err = tx.QueryRow(ctx, ` -INSERT INTO wallets (address, mnemonic) -VALUES ($1, $2) RETURNING id - `, walletAddress, walletMnemonic).Scan(&walletID) - if err != nil { - return 0, fmt.Errorf("insert wallet failed: %w", err) - } - - var contestID int - err = tx.QueryRow(ctx, ` -INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id - `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin, walletID).Scan(&contestID) if err != nil { return 0, fmt.Errorf("insert contest failed: %w", err) } @@ -105,7 +49,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id contestID, pid, string(charcodes[i])) } - br := tx.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) for i := 0; i < len(problemIDs); i++ { if _, err := br.Exec(); err != nil { @@ -118,10 +62,6 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id return 0, fmt.Errorf("batch close failed: %w", err) } - if err := tx.Commit(ctx); err != nil { - return 0, fmt.Errorf("commit failed: %w", err) - } - return contestID, nil } @@ -136,7 +76,7 @@ JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id WHERE c.id = $1 GROUP BY c.id, u.username` - err := p.pool.QueryRow(ctx, query, contestID).Scan( + err := p.conn.QueryRow(ctx, query, contestID).Scan( &contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, &contest.WalletID, &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) @@ -150,7 +90,7 @@ SELECT w.id, w.address, w.mnemonic, w.created_at FROM wallets w WHERE w.id = $1` - err := p.pool.QueryRow(ctx, query, walletID).Scan(&wallet.ID, &wallet.Address, &wallet.Mnemonic, &wallet.CreatedAt) + err := p.conn.QueryRow(ctx, query, walletID).Scan(&wallet.ID, &wallet.Address, &wallet.Mnemonic, &wallet.CreatedAt) return wallet, err } @@ -164,7 +104,7 @@ JOIN contest_problems cp ON p.id = cp.problem_id JOIN users u ON u.id = p.writer_id WHERE cp.contest_id = $1 ORDER BY charcode ASC` - rows, err := p.pool.Query(ctx, query, contestID) + rows, err := p.conn.Query(ctx, query, contestID) if err != nil { return nil, err } @@ -202,7 +142,7 @@ LIMIT $1 OFFSET $2 batch.Queue(`SELECT COUNT(*) FROM contests WHERE contests.end_time >= now()`) - br := p.pool.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) rows, err := br.Query() if err != nil { @@ -256,7 +196,7 @@ LIMIT $2 OFFSET $3 batch.Queue(`SELECT COUNT(*) FROM contests WHERE creator_id = $1`, creatorID) - br := p.pool.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) rows, err := br.Query() if err != nil { @@ -295,13 +235,13 @@ LIMIT $2 OFFSET $3 func (p *Postgres) GetEntriesCount(ctx context.Context, contestID int) (int, error) { var count int - err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM entries WHERE contest_id = $1`, contestID).Scan(&count) + err := p.conn.QueryRow(ctx, `SELECT COUNT(*) FROM entries WHERE contest_id = $1`, contestID).Scan(&count) return count, err } func (p *Postgres) IsTitleOccupied(ctx context.Context, title string) (bool, error) { var count int - err := p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM contests WHERE LOWER(title) = $1`, strings.ToLower(title)).Scan(&count) + err := p.conn.QueryRow(ctx, `SELECT COUNT(*) FROM contests WHERE LOWER(title) = $1`, strings.ToLower(title)).Scan(&count) return count > 0, err } @@ -338,7 +278,7 @@ func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset WHERE e.contest_id = $1 `, contestID) - br := p.pool.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) rows, err := br.Query() if err != nil { diff --git a/internal/storage/repository/postgres/entry/entry.go b/internal/storage/repository/postgres/entry/entry.go index b5e9288..5de4e78 100644 --- a/internal/storage/repository/postgres/entry/entry.go +++ b/internal/storage/repository/postgres/entry/entry.go @@ -3,23 +3,23 @@ package entry import ( "context" - "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" ) type Postgres struct { - pool *pgxpool.Pool + conn postgres.Transactor } -func New(pool *pgxpool.Pool) *Postgres { - return &Postgres{pool} +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn} } func (p *Postgres) Create(ctx context.Context, contestID int, userID int) (int, error) { query := `INSERT INTO entries (contest_id, user_id) VALUES ($1, $2) RETURNING id` var id int - err := p.pool.QueryRow(ctx, query, contestID, userID).Scan(&id) + err := p.conn.QueryRow(ctx, query, contestID, userID).Scan(&id) if err != nil { return 0, err } @@ -31,7 +31,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E WHERE contest_id = $1 AND user_id = $2` var entry models.Entry - err := p.pool.QueryRow(ctx, query, contestID, userID).Scan( + err := p.conn.QueryRow(ctx, query, contestID, userID).Scan( &entry.ID, &entry.ContestID, &entry.UserID, diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index 213285a..bc8f8e0 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -5,8 +5,8 @@ import ( "database/sql" "fmt" - "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" ) const ( @@ -14,11 +14,11 @@ const ( ) type Postgres struct { - pool *pgxpool.Pool + conn postgres.Transactor } -func New(pool *pgxpool.Pool) *Postgres { - return &Postgres{pool} +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn} } func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code string, language string) (models.Submission, error) { @@ -27,7 +27,7 @@ func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code RETURNING id, entry_id, problem_id, status, verdict, code, language, created_at` var submission models.Submission - err := p.pool.QueryRow(ctx, query, entryID, problemID, code, language).Scan( + err := p.conn.QueryRow(ctx, query, entryID, problemID, code, language).Scan( &submission.ID, &submission.EntryID, &submission.ProblemID, @@ -54,7 +54,7 @@ func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int, problemID ` var status sql.NullString - err := p.pool.QueryRow(ctx, query, entryID, problemID).Scan(&status) + err := p.conn.QueryRow(ctx, query, entryID, problemID).Scan(&status) if err != nil { return "", fmt.Errorf("query failed: %w", err) } @@ -79,7 +79,7 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int) (map[int GROUP BY s.problem_id ` - rows, err := p.pool.Query(ctx, query, entryID) + rows, err := p.conn.Query(ctx, query, entryID) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } @@ -114,7 +114,7 @@ func (p *Postgres) GetByID(ctx context.Context, submissionID int) (models.Submis FROM submissions s WHERE s.id = $1` var s models.Submission - err := p.pool.QueryRow(ctx, query, submissionID).Scan( + err := p.conn.QueryRow(ctx, query, submissionID).Scan( &s.ID, &s.EntryID, &s.ProblemID, @@ -143,7 +143,7 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int, charcode stri ORDER BY s.created_at DESC LIMIT $3 OFFSET $4` - rows, err := p.pool.Query(ctx, query, entryID, charcode, limit, offset) + rows, err := p.conn.Query(ctx, query, entryID, charcode, limit, offset) if err != nil { return nil, 0, fmt.Errorf("query rows failed: %w", err) } @@ -180,7 +180,7 @@ func (p *Postgres) GetTestingReport(ctx context.Context, submissionID int) (mode FROM testing_reports WHERE submission_id = $1` var report models.TestingReport - err := p.pool.QueryRow(ctx, query, submissionID).Scan( + err := p.conn.QueryRow(ctx, query, submissionID).Scan( &report.ID, &report.SubmissionID, &report.PassedTestsCount, diff --git a/internal/storage/repository/postgres/txmanager.go b/internal/storage/repository/postgres/txmanager.go new file mode 100644 index 0000000..7543041 --- /dev/null +++ b/internal/storage/repository/postgres/txmanager.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// TxManager manages database transactions +type TxManager struct { + pool *pgxpool.Pool +} + +// NewTxManager creates a new transaction manager +func NewTxManager(pool *pgxpool.Pool) *TxManager { + return &TxManager{pool: pool} +} + +// Transactor is an interface that can be either a pool or a transaction +type Transactor interface { + Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row + SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults +} + +// WithinTransaction executes a function within a database transaction +// If the function returns an error, the transaction is rolled back +// Otherwise, the transaction is committed +func (tm *TxManager) WithinTransaction(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error { + tx, err := tm.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + if err := fn(ctx, tx); err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit transaction: %w", err) + } + + return nil +} diff --git a/internal/storage/repository/postgres/user/user.go b/internal/storage/repository/postgres/user/user.go index f7223f2..98bd69b 100644 --- a/internal/storage/repository/postgres/user/user.go +++ b/internal/storage/repository/postgres/user/user.go @@ -3,23 +3,23 @@ package user import ( "context" - "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" ) type Postgres struct { - pool *pgxpool.Pool + conn postgres.Transactor } -func New(pool *pgxpool.Pool) *Postgres { - return &Postgres{pool} +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn} } func (p *Postgres) GetByCredentials(ctx context.Context, username string, passwordHash string) (models.User, error) { var user models.User query := `SELECT id, username, password_hash, role_id, created_at FROM users WHERE username = $1 AND password_hash = $2` - err := p.pool.QueryRow(ctx, query, username, passwordHash).Scan( + err := p.conn.QueryRow(ctx, query, username, passwordHash).Scan( &user.ID, &user.Username, &user.PasswordHash, @@ -38,7 +38,7 @@ func (p *Postgres) Create(ctx context.Context, username string, passwordHash str RETURNING id, username, password_hash, role_id, created_at ` - err := p.pool.QueryRow(ctx, query, username, passwordHash).Scan( + err := p.conn.QueryRow(ctx, query, username, passwordHash).Scan( &user.ID, &user.Username, &user.PasswordHash, @@ -52,7 +52,7 @@ func (p *Postgres) Exists(ctx context.Context, username string) (bool, error) { var count int query := `SELECT COUNT(*) FROM users WHERE username = $1` - err := p.pool.QueryRow(ctx, query, username).Scan(&count) + err := p.conn.QueryRow(ctx, query, username).Scan(&count) if err != nil { return false, err } @@ -64,7 +64,7 @@ func (p *Postgres) GetByID(ctx context.Context, id int) (models.User, error) { var user models.User query := `SELECT id, username, password_hash, role_id, created_at FROM users WHERE id = $1` - err := p.pool.QueryRow(ctx, query, id).Scan( + err := p.conn.QueryRow(ctx, query, id).Scan( &user.ID, &user.Username, &user.PasswordHash, @@ -83,7 +83,7 @@ func (p *Postgres) GetRole(ctx context.Context, userID int) (models.Role, error) JOIN roles r ON u.role_id = r.id WHERE u.id = $1 ` - err := p.pool.QueryRow(ctx, query, userID).Scan( + err := p.conn.QueryRow(ctx, query, userID).Scan( &role.ID, &role.Name, &role.CreatedProblemsLimit, @@ -98,7 +98,7 @@ func (p *Postgres) GetCreatedProblemsCount(ctx context.Context, userID int) (int var count int query := `SELECT COUNT(*) FROM problems WHERE writer_id = $1` - err := p.pool.QueryRow(ctx, query, userID).Scan(&count) + err := p.conn.QueryRow(ctx, query, userID).Scan(&count) return count, err } @@ -106,6 +106,6 @@ func (p *Postgres) GetCreatedContestsCount(ctx context.Context, userID int) (int var count int query := `SELECT COUNT(*) FROM contests WHERE creator_id = $1` - err := p.pool.QueryRow(ctx, query, userID).Scan(&count) + err := p.conn.QueryRow(ctx, query, userID).Scan(&count) return count, err } diff --git a/internal/storage/repository/postgres/wallet/wallet.go b/internal/storage/repository/postgres/wallet/wallet.go new file mode 100644 index 0000000..264b944 --- /dev/null +++ b/internal/storage/repository/postgres/wallet/wallet.go @@ -0,0 +1,27 @@ +package wallet + +import ( + "context" + "fmt" + + "github.com/voidcontests/api/internal/storage/repository/postgres" +) + +type Postgres struct { + tx postgres.Transactor +} + +func New(txr postgres.Transactor) *Postgres { + return &Postgres{tx: txr} +} + +func (p *Postgres) Create(ctx context.Context, address, mnemonic string) (int, error) { + var walletID int + query := `INSERT INTO wallets (address, mnemonic) VALUES ($1, $2) RETURNING id` + err := p.tx.QueryRow(ctx, query, address, mnemonic).Scan(&walletID) + if err != nil { + return 0, fmt.Errorf("insert wallet failed: %w", err) + } + + return walletID, nil +} diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 2695489..4caec89 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -4,13 +4,16 @@ import ( "context" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" "github.com/voidcontests/api/internal/storage/repository/postgres/contest" "github.com/voidcontests/api/internal/storage/repository/postgres/entry" "github.com/voidcontests/api/internal/storage/repository/postgres/problem" "github.com/voidcontests/api/internal/storage/repository/postgres/submission" "github.com/voidcontests/api/internal/storage/repository/postgres/user" + "github.com/voidcontests/api/internal/storage/repository/postgres/wallet" ) type Repository struct { @@ -19,6 +22,7 @@ type Repository struct { Problem Problem Entry Entry Submission Submission + TxManager *postgres.TxManager } func New(pool *pgxpool.Pool) *Repository { @@ -28,6 +32,28 @@ func New(pool *pgxpool.Pool) *Repository { Problem: problem.New(pool), Entry: entry.New(pool), Submission: submission.New(pool), + TxManager: postgres.NewTxManager(pool), + } +} + +// TxRepository provides repository instances within a transaction +type TxRepository struct { + User User + Contest Contest + Problem Problem + Entry Entry + Submission Submission + Wallet Wallet +} + +// NewTxRepository creates repository instances that use the provided transaction +func NewTxRepository(tx pgx.Tx) *TxRepository { + return &TxRepository{ + Contest: contest.New(tx), + Wallet: wallet.New(tx), + User: user.New(tx), + Entry: entry.New(tx), + Submission: submission.New(tx), } } @@ -42,8 +68,7 @@ type User interface { } type Contest interface { - Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int) (int, error) - CreateWithWallet(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletAddress, walletMnemonic string) (int, error) + Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) GetByID(ctx context.Context, contestID int) (models.Contest, error) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) ListAll(ctx context.Context, limit int, offset int) (contests []models.Contest, total int, err error) @@ -54,6 +79,10 @@ type Contest interface { GetWallet(ctx context.Context, walletID int) (models.Wallet, error) } +type Wallet interface { + Create(ctx context.Context, address, mnemonic string) (int, error) +} + type Problem interface { CreateWithTCs(ctx context.Context, writerID int, title string, statement string, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int, error) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) From d2151a348598e8de55bd563fe786379d5b79367d Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 18:46:32 +0400 Subject: [PATCH 09/56] chore: add problem repo to txrepo --- internal/app/service/problem.go | 27 ++++--- .../repository/postgres/problem/problem.go | 72 +++++++++---------- internal/storage/repository/repository.go | 4 +- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index cb6ec72..3241350 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -81,17 +81,22 @@ func (s *ProblemService) CreateProblem(ctx context.Context, params CreateProblem checker = "tokens" } - problemID, err := s.repo.Problem.CreateWithTCs( - ctx, - params.UserID, - params.Title, - params.Statement, - params.Difficulty, - params.TimeLimitMS, - params.MemoryLimitMB, - checker, - params.TestCases, - ) + var problemID int + err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + repo := repository.NewTxRepository(tx) + + problemID, err = repo.Problem.Create(ctx, params.UserID, params.Title, params.Statement, params.Difficulty, params.TimeLimitMS, params.MemoryLimitMB, params.Checker) + if err != nil { + return err + } + + err = repo.Problem.AssociateTestCases(ctx, problemID, params.TestCases) + if err != nil { + return err + } + + return nil + }) if err != nil { return 0, fmt.Errorf("%s: failed to create problem: %w", op, err) } diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index 947617d..ad4f0e2 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -5,27 +5,21 @@ import ( "fmt" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" ) type Postgres struct { - pool *pgxpool.Pool + conn postgres.Transactor } -func New(pool *pgxpool.Pool) *Postgres { - return &Postgres{pool} +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn: conn} } -func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int, error) { - tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return 0, fmt.Errorf("tx begin failed: %w", err) - } - defer tx.Rollback(ctx) - +func (p *Postgres) Create(ctx context.Context, writerID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string) (int, error) { var problemID int - err = tx.QueryRow(ctx, ` + err := p.conn.QueryRow(ctx, ` INSERT INTO problems (writer_id, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id @@ -34,34 +28,36 @@ func (p *Postgres) CreateWithTCs(ctx context.Context, writerID int, title, state return 0, fmt.Errorf("insert problem failed: %w", err) } - if len(tcs) > 0 { - batch := &pgx.Batch{} - for i, tc := range tcs { - batch.Queue(` - INSERT INTO test_cases (problem_id, ordinal, input, output, is_example) - VALUES ($1, $2, $3, $4, $5) - `, problemID, i+1, tc.Input, tc.Output, tc.IsExample) - } + return problemID, nil +} - br := tx.SendBatch(ctx, batch) +func (p *Postgres) AssociateTestCases(ctx context.Context, problemID int, tcs []models.TestCaseDTO) error { + if len(tcs) == 0 { + return nil + } - for i := 0; i < batch.Len(); i++ { - if _, err := br.Exec(); err != nil { - br.Close() - return 0, fmt.Errorf("insert test case %d failed: %w", i, err) - } - } + batch := &pgx.Batch{} + for i, tc := range tcs { + batch.Queue(` + INSERT INTO test_cases (problem_id, ordinal, input, output, is_example) + VALUES ($1, $2, $3, $4, $5) + `, problemID, i+1, tc.Input, tc.Output, tc.IsExample) + } - if err := br.Close(); err != nil { - return 0, fmt.Errorf("batch close failed: %w", err) + br := p.conn.SendBatch(ctx, batch) + defer br.Close() + + for i := 0; i < batch.Len(); i++ { + if _, err := br.Exec(); err != nil { + return fmt.Errorf("insert test case %d failed: %w", i, err) } } - if err := tx.Commit(ctx); err != nil { - return 0, fmt.Errorf("commit failed: %w", err) + if err := br.Close(); err != nil { + return fmt.Errorf("batch close failed: %w", err) } - return problemID, nil + return nil } func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { @@ -74,7 +70,7 @@ JOIN contest_problems cp ON p.id = cp.problem_id JOIN users u ON u.id = p.writer_id WHERE cp.contest_id = $1 AND cp.charcode = $2` - row := p.pool.QueryRow(ctx, query, contestID, charcode) + row := p.conn.QueryRow(ctx, query, contestID, charcode) var problem models.Problem err := row.Scan( @@ -95,7 +91,7 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, JOIN users u ON u.id = p.writer_id WHERE p.id = $1` - row := p.pool.QueryRow(ctx, query, problemID) + row := p.conn.QueryRow(ctx, query, problemID) var problem models.Problem err := row.Scan( @@ -110,7 +106,7 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, func (p *Postgres) GetExampleCases(ctx context.Context, problemID int) ([]models.TestCase, error) { query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE problem_id = $1 AND is_example = true` - rows, err := p.pool.Query(ctx, query, problemID) + rows, err := p.conn.Query(ctx, query, problemID) if err != nil { return nil, err } @@ -132,7 +128,7 @@ func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models. query := `SELECT id, problem_id, ordinal, input, output, is_example FROM test_cases WHERE id = $1` var tc models.TestCase - err := p.pool.QueryRow(ctx, query, testCaseID).Scan( + err := p.conn.QueryRow(ctx, query, testCaseID).Scan( &tc.ID, &tc.ProblemID, &tc.Ordinal, @@ -156,7 +152,7 @@ SELECT FROM problems p JOIN users u ON u.id = p.writer_id` - rows, err := p.pool.Query(ctx, query) + rows, err := p.conn.Query(ctx, query) if err != nil { return nil, err } @@ -195,7 +191,7 @@ LIMIT $2 OFFSET $3 SELECT COUNT(*) FROM problems WHERE writer_id = $1 `, writerID) - br := p.pool.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) rows, err := br.Query() if err != nil { diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 4caec89..b722d70 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -54,6 +54,7 @@ func NewTxRepository(tx pgx.Tx) *TxRepository { User: user.New(tx), Entry: entry.New(tx), Submission: submission.New(tx), + Problem: problem.New(tx), } } @@ -84,7 +85,8 @@ type Wallet interface { } type Problem interface { - CreateWithTCs(ctx context.Context, writerID int, title string, statement string, difficulty string, timeLimitMS, memoryLimitMB int, checker string, tcs []models.TestCaseDTO) (int, error) + Create(ctx context.Context, writerID int, title, statement, difficulty string, timeLimitMS, memoryLimitMB int, checker string) (int, error) + AssociateTestCases(ctx context.Context, problemID int, tcs []models.TestCaseDTO) error Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) GetByID(ctx context.Context, problemID int) (models.Problem, error) GetExampleCases(ctx context.Context, problemID int) ([]models.TestCase, error) From 0dbe2d6c32263d684a995110bf4e2c2051e86ad8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 19:09:20 +0400 Subject: [PATCH 10/56] chore: improve fixes after review --- internal/app/service/problem.go | 17 ++++++++++++++--- .../repository/postgres/contest/contest.go | 7 ++++++- .../repository/postgres/problem/problem.go | 2 +- .../repository/postgres/wallet/wallet.go | 8 ++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index 3241350..54dff73 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -81,18 +81,29 @@ func (s *ProblemService) CreateProblem(ctx context.Context, params CreateProblem checker = "tokens" } + const MAX_TEST_CASES = 100 + if len(params.TestCases) > MAX_TEST_CASES { + return 0, fmt.Errorf("too many test cases: got %d, max %d", len(params.TestCases), MAX_TEST_CASES) + } + + for i, tc := range params.TestCases { + if tc.Input == "" && tc.Output == "" { + return 0, fmt.Errorf("test case %d: both input and output are empty", i) + } + } + var problemID int err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { repo := repository.NewTxRepository(tx) - problemID, err = repo.Problem.Create(ctx, params.UserID, params.Title, params.Statement, params.Difficulty, params.TimeLimitMS, params.MemoryLimitMB, params.Checker) + problemID, err = repo.Problem.Create(ctx, params.UserID, params.Title, params.Statement, params.Difficulty, params.TimeLimitMS, params.MemoryLimitMB, checker) if err != nil { - return err + return fmt.Errorf("create problem: %w", err) } err = repo.Problem.AssociateTestCases(ctx, problemID, params.TestCases) if err != nil { - return err + return fmt.Errorf("associate test cases: %w", err) } return nil diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 82cb2b7..e2edee6 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -21,9 +21,14 @@ func New(txr postgres.Transactor) *Postgres { return &Postgres{conn: txr} } +// TODO: associate problem with charcode at the service layer func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) { charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + if len(problemIDs) > len(charcodes) { + return 0, fmt.Errorf("too many problems: got %d, max %d", len(problemIDs), len(charcodes)) + } + var contestID int var err error @@ -54,7 +59,7 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id for i := 0; i < len(problemIDs); i++ { if _, err := br.Exec(); err != nil { br.Close() - return 0, fmt.Errorf("insert contest_problem %d failed: %w", i, err) + return 0, fmt.Errorf("insert contest_problem %d (problem_id=%d, charcode=%s) failed: %w", i, problemIDs[i], string(charcodes[i]), err) } } diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index ad4f0e2..ba79214 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -49,7 +49,7 @@ func (p *Postgres) AssociateTestCases(ctx context.Context, problemID int, tcs [] for i := 0; i < batch.Len(); i++ { if _, err := br.Exec(); err != nil { - return fmt.Errorf("insert test case %d failed: %w", i, err) + return fmt.Errorf("insert test case %d (ordinal=%d, is_example=%v) failed: %w", i, i+1, tcs[i].IsExample, err) } } diff --git a/internal/storage/repository/postgres/wallet/wallet.go b/internal/storage/repository/postgres/wallet/wallet.go index 264b944..ce12052 100644 --- a/internal/storage/repository/postgres/wallet/wallet.go +++ b/internal/storage/repository/postgres/wallet/wallet.go @@ -8,17 +8,17 @@ import ( ) type Postgres struct { - tx postgres.Transactor + conn postgres.Transactor } -func New(txr postgres.Transactor) *Postgres { - return &Postgres{tx: txr} +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn: conn} } func (p *Postgres) Create(ctx context.Context, address, mnemonic string) (int, error) { var walletID int query := `INSERT INTO wallets (address, mnemonic) VALUES ($1, $2) RETURNING id` - err := p.tx.QueryRow(ctx, query, address, mnemonic).Scan(&walletID) + err := p.conn.QueryRow(ctx, query, address, mnemonic).Scan(&walletID) if err != nil { return 0, fmt.Errorf("insert wallet failed: %w", err) } From 1d151c4683a24f0f8c94a4a17ba7c07a1730fbcc Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 21:41:57 +0400 Subject: [PATCH 11/56] chore: ton transfer: add comment optional --- pkg/ton/ton.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 995ad17..5d305e2 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -81,10 +81,11 @@ func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint return account.State.Balance.Nano().Uint64(), nil } -func (w *Wallet) TransferTo(ctx context.Context, recipient *address.Address, amount tlb.Coins, comment string) (tx string, err error) { +func (w *Wallet) TransferTo(ctx context.Context, recipient *address.Address, amount tlb.Coins, comments ...string) (tx string, err error) { var body *cell.Cell - if comment != "" { - var err error + + if len(comments) > 0 { + comment := strings.Join(comments, ", ") body, err = wallet.CreateCommentCell(comment) if err != nil { return "", fmt.Errorf("failed to create comment cell: %w", err) From 164557709e045ca0bac908119cc8afbb3cf4cc63 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 22:16:28 +0400 Subject: [PATCH 12/56] fix: incorrect participants count --- internal/storage/repository/postgres/contest/contest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index e2edee6..81acd35 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -135,7 +135,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests batch.Queue(` SELECT c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(u.id) AS participants + c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id From b791a0f253f483b1205287cd47d17df5fc46a329 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 4 Nov 2025 22:45:05 +0400 Subject: [PATCH 13/56] chore: add creator_id and title filters for contest querying --- internal/app/handler/contest.go | 15 +++++- internal/app/router/router.go | 3 +- internal/app/service/contest.go | 4 +- internal/storage/models/models.go | 5 ++ .../repository/postgres/contest/contest.go | 52 +++++++++++++++++-- internal/storage/repository/repository.go | 2 +- 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 79bdf72..e240c83 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -181,7 +181,20 @@ func (h *Handler) GetContests(c echo.Context) error { offset = 0 } - result, err := h.service.Contest.ListAllContests(ctx, limit, offset) + filters := models.ContestFilters{} + + if creatorID, ok := ExtractQueryParamInt(c, "creator_id"); ok { + if creatorID > 0 { + return Error(http.StatusBadRequest, "creator_id should be a valid integer, greater 0") + } + filters.CreatorID = creatorID + } + + if title := c.QueryParam("title"); title != "" { + filters.Title = title + } + + result, err := h.service.Contest.ListAllContests(ctx, limit, offset, filters) if err != nil { return err } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index a73d547..bfa6b30 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -83,7 +83,8 @@ func (r *Router) InitRoutes() *echo.Echo { api.POST("/account", r.handler.CreateAccount) api.POST("/session", r.handler.CreateSession) - // TODO: make this endpoints as filter to general endpoint, like: + // TODO: Migrate to /api/contests with query parameters + // DONE: make this endpoints as filter to general endpoint, like: // GET /contests?creator_id=69 // GET /problems?writer_id=420 api.GET("/creator/contests", r.handler.GetCreatedContests, r.handler.MustIdentify()) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 1ed067f..79241c9 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -231,10 +231,10 @@ func (s *ContestService) ListCreatedContests(ctx context.Context, creatorID int, }, nil } -func (s *ContestService) ListAllContests(ctx context.Context, limit, offset int) (*ListContestsResult, error) { +func (s *ContestService) ListAllContests(ctx context.Context, limit, offset int, filters models.ContestFilters) (*ListContestsResult, error) { op := "service.ContestService.ListAllContests" - contests, total, err := s.repo.Contest.ListAll(ctx, limit, offset) + contests, total, err := s.repo.Contest.ListAll(ctx, limit, offset, filters) if err != nil { return nil, fmt.Errorf("%s: failed to list all contests: %w", op, err) } diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 6fcde16..49f75ce 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -42,6 +42,11 @@ type Contest struct { CreatedAt time.Time `db:"created_at"` } +type ContestFilters struct { + CreatorID int + Title string +} + type Wallet struct { ID int `db:"id"` Address string `db:"address"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 81acd35..85fee66 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -126,26 +126,68 @@ WHERE cp.contest_id = $1 ORDER BY charcode ASC` return problems, nil } -func (p *Postgres) ListAll(ctx context.Context, limit int, offset int) (contests []models.Contest, total int, err error) { +func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters models.ContestFilters) (contests []models.Contest, total int, err error) { if limit < 0 { limit = defaultLimit } batch := &pgx.Batch{} - batch.Queue(` + + whereClauses := []string{"c.end_time >= now()"} + queryArgs := []interface{}{limit, offset} + countArgs := []interface{}{} + paramIndex := 3 + + if filters.CreatorID != 0 { + whereClauses = append(whereClauses, fmt.Sprintf("c.creator_id = $%d", paramIndex)) + queryArgs = append(queryArgs, filters.CreatorID) + countArgs = append(countArgs, filters.CreatorID) + paramIndex++ + } + + if filters.Title != "" { + whereClauses = append(whereClauses, fmt.Sprintf("LOWER(c.title) LIKE LOWER($%d)", paramIndex)) + queryArgs = append(queryArgs, "%"+filters.Title+"%") + countArgs = append(countArgs, "%"+filters.Title+"%") + paramIndex++ + } + + whereClause := "WHERE " + strings.Join(whereClauses, " AND ") + + query := fmt.Sprintf(` SELECT c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id -WHERE c.end_time >= now() +%s GROUP BY c.id, u.username ORDER BY c.id ASC LIMIT $1 OFFSET $2 - `, limit, offset) + `, whereClause) - batch.Queue(`SELECT COUNT(*) FROM contests WHERE contests.end_time >= now()`) + batch.Queue(query, queryArgs...) + + countWhereClauses := []string{"contests.end_time >= now()"} + if filters.CreatorID != 0 { + countWhereClauses = append(countWhereClauses, "contests.creator_id = $1") + } + if filters.Title != "" { + countParamIndex := 1 + if filters.CreatorID != 0 { + countParamIndex = 2 + } + countWhereClauses = append(countWhereClauses, fmt.Sprintf("LOWER(contests.title) LIKE LOWER($%d)", countParamIndex)) + } + countWhereClause := strings.Join(countWhereClauses, " AND ") + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM contests WHERE %s", countWhereClause) + + if len(countArgs) > 0 { + batch.Queue(countQuery, countArgs...) + } else { + batch.Queue(countQuery) + } br := p.conn.SendBatch(ctx, batch) diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index b722d70..77228a2 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -72,7 +72,7 @@ type Contest interface { Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) GetByID(ctx context.Context, contestID int) (models.Contest, error) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) - ListAll(ctx context.Context, limit int, offset int) (contests []models.Contest, total int, err error) + ListAll(ctx context.Context, limit int, offset int, filters models.ContestFilters) (contests []models.Contest, total int, err error) GetWithCreatorID(ctx context.Context, creatorID int, limit, offset int) (contests []models.Contest, total int, err error) GetEntriesCount(ctx context.Context, contestID int) (int, error) IsTitleOccupied(ctx context.Context, title string) (bool, error) From 832e29ebcdc1bc8d5de5d48017eef5ebab13a493 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 14:33:55 +0400 Subject: [PATCH 14/56] chore: add leaderboard view --- .../repository/postgres/contest/contest.go | 52 ++++--------------- migrations/000001_init.down.sql | 2 + migrations/000001_init.up.sql | 23 ++++++++ 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 85fee66..20029e6 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -293,64 +293,30 @@ func (p *Postgres) IsTitleOccupied(ctx context.Context, title string) (bool, err } func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) { - batch := &pgx.Batch{} - batch.Queue(` - SELECT u.id AS user_id, u.username, COALESCE(SUM( - CASE - WHEN p.difficulty = 'easy' THEN 1 - WHEN p.difficulty = 'mid' THEN 3 - WHEN p.difficulty = 'hard' THEN 5 - ELSE 0 - END - ), 0) AS points - FROM users u - JOIN entries e ON u.id = e.user_id - JOIN contests c ON e.contest_id = c.id - LEFT JOIN ( - SELECT DISTINCT entry_id, problem_id - FROM submissions - WHERE verdict = 'ok' - ) s ON e.id = s.entry_id - LEFT JOIN problems p ON s.problem_id = p.id - WHERE c.id = $1 - GROUP BY u.id, u.username + query := ` + SELECT user_id, username, points, COUNT(*) OVER() AS total + FROM leaderboard + WHERE contest_id = $1 ORDER BY points DESC LIMIT $2 OFFSET $3 - `, contestID, limit, offset) - - batch.Queue(` - SELECT COUNT(DISTINCT u.id) - FROM users u - JOIN entries e ON u.id = e.user_id - WHERE e.contest_id = $1 - `, contestID) + ` - br := p.conn.SendBatch(ctx, batch) - - rows, err := br.Query() + rows, err := p.conn.Query(ctx, query, contestID, limit, offset) if err != nil { - br.Close() return nil, 0, fmt.Errorf("leaderboard query failed: %w", err) } + defer rows.Close() leaderboard = make([]models.LeaderboardEntry, 0) for rows.Next() { var entry models.LeaderboardEntry - if err := rows.Scan(&entry.UserID, &entry.Username, &entry.Points); err != nil { - rows.Close() - br.Close() + if err := rows.Scan(&entry.UserID, &entry.Username, &entry.Points, &total); err != nil { return nil, 0, err } leaderboard = append(leaderboard, entry) } - rows.Close() - if err := br.QueryRow().Scan(&total); err != nil { - br.Close() - return nil, 0, fmt.Errorf("total count query failed: %w", err) - } - - if err := br.Close(); err != nil { + if err := rows.Err(); err != nil { return nil, 0, err } diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql index 699c8c6..2542934 100644 --- a/migrations/000001_init.down.sql +++ b/migrations/000001_init.down.sql @@ -1,3 +1,5 @@ +DROP VIEW IF EXISTS leaderboard; + DROP TABLE IF EXISTS testing_reports; DROP TABLE IF EXISTS submissions; DROP TABLE IF EXISTS entries; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index e2241dc..3f1b622 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -110,3 +110,26 @@ CREATE TABLE testing_reports ( stderr TEXT DEFAULT '' NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); + +CREATE VIEW leaderboard AS +SELECT + e.contest_id, + u.id AS user_id, + u.username, + COALESCE(SUM( + CASE + WHEN p.difficulty = 'easy' THEN 1 + WHEN p.difficulty = 'mid' THEN 3 + WHEN p.difficulty = 'hard' THEN 5 + ELSE 0 + END + ), 0) AS points +FROM users u +JOIN entries e ON u.id = e.user_id +LEFT JOIN ( + SELECT DISTINCT entry_id, problem_id + FROM submissions + WHERE verdict = 'ok' +) s ON e.id = s.entry_id +LEFT JOIN problems p ON s.problem_id = p.id +GROUP BY e.contest_id, u.id, u.username; From de6ebeb7feda001e9103d6642b90c527632478fc Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 14:41:09 +0400 Subject: [PATCH 15/56] chore: factor out charcode calculation from repo layer --- internal/app/service/contest.go | 17 ++++++++- internal/storage/models/models.go | 5 +++ .../repository/postgres/contest/contest.go | 37 ++++++++----------- internal/storage/repository/repository.go | 2 +- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 79241c9..2b39772 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -63,6 +63,19 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest } } + const charcodes = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + if len(params.ProblemIDs) > len(charcodes) { + return 0, fmt.Errorf("too many problems: got %d, max %d", len(params.ProblemIDs), len(charcodes)) + } + + problems := make([]models.ProblemCharcode, len(params.ProblemIDs)) + for i, problemID := range params.ProblemIDs { + problems[i] = models.ProblemCharcode{ + ProblemID: problemID, + Charcode: string(charcodes[i]), + } + } + // NOTE: if award type is not `paid_entry` or `sponsored` - use `no_prize` by default var contestID int if params.AwardType == "paid_entry" || params.AwardType == "sponsored" { @@ -93,7 +106,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest params.DurationMins, params.MaxEntries, params.AllowLateJoin, - params.ProblemIDs, + problems, &walletID, ) if err != nil { @@ -117,7 +130,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest params.DurationMins, params.MaxEntries, params.AllowLateJoin, - params.ProblemIDs, + problems, nil, ) if err != nil { diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 49f75ce..6e1a262 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -47,6 +47,11 @@ type ContestFilters struct { Title string } +type ProblemCharcode struct { + ProblemID int + Charcode string +} + type Wallet struct { ID int `db:"id"` Address string `db:"address"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 20029e6..3c463e1 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -21,14 +21,7 @@ func New(txr postgres.Transactor) *Postgres { return &Postgres{conn: txr} } -// TODO: associate problem with charcode at the service layer -func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) { - charcodes := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - - if len(problemIDs) > len(charcodes) { - return 0, fmt.Errorf("too many problems: got %d, max %d", len(problemIDs), len(charcodes)) - } - +func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) { var contestID int var err error @@ -48,23 +41,25 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id return 0, fmt.Errorf("insert contest failed: %w", err) } - batch := &pgx.Batch{} - for i, pid := range problemIDs { - batch.Queue(`INSERT INTO contest_problems (contest_id, problem_id, charcode) VALUES ($1, $2, $3)`, - contestID, pid, string(charcodes[i])) - } + if len(problems) > 0 { + batch := &pgx.Batch{} + for _, p := range problems { + batch.Queue(`INSERT INTO contest_problems (contest_id, problem_id, charcode) VALUES ($1, $2, $3)`, + contestID, p.ProblemID, p.Charcode) + } - br := p.conn.SendBatch(ctx, batch) + br := p.conn.SendBatch(ctx, batch) - for i := 0; i < len(problemIDs); i++ { - if _, err := br.Exec(); err != nil { - br.Close() - return 0, fmt.Errorf("insert contest_problem %d (problem_id=%d, charcode=%s) failed: %w", i, problemIDs[i], string(charcodes[i]), err) + for i := 0; i < len(problems); i++ { + if _, err := br.Exec(); err != nil { + br.Close() + return 0, fmt.Errorf("insert contest_problem %d (problem_id=%d, charcode=%s) failed: %w", i, problems[i].ProblemID, problems[i].Charcode, err) + } } - } - if err := br.Close(); err != nil { - return 0, fmt.Errorf("batch close failed: %w", err) + if err := br.Close(); err != nil { + return 0, fmt.Errorf("batch close failed: %w", err) + } } return contestID, nil diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 77228a2..148d689 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -69,7 +69,7 @@ type User interface { } type Contest interface { - Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problemIDs []int, walletID *int) (int, error) + Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) GetByID(ctx context.Context, contestID int) (models.Contest, error) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) ListAll(ctx context.Context, limit int, offset int, filters models.ContestFilters) (contests []models.Contest, total int, err error) From e4d7f182c6ff6f33769d095224d0283347e99468 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 15:18:57 +0400 Subject: [PATCH 16/56] chore: add some views --- .../repository/postgres/contest/contest.go | 61 ++++++++---------- .../repository/postgres/problem/problem.go | 39 +++++------ .../postgres/submission/submission.go | 24 ++----- migrations/000001_init.down.sql | 4 ++ migrations/000001_init.up.sql | 64 +++++++++++++++++++ 5 files changed, 117 insertions(+), 75 deletions(-) diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 3c463e1..1a45daa 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -69,13 +69,10 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, var contest models.Contest query := ` SELECT - c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants -FROM contests c -JOIN users u ON u.id = c.creator_id -LEFT JOIN entries e ON e.contest_id = c.id -WHERE c.id = $1 -GROUP BY c.id, u.username` + id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, + allow_late_join, wallet_id, created_at, creator_username, participants +FROM contest_details +WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( &contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, @@ -97,12 +94,10 @@ WHERE w.id = $1` func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { query := ` SELECT - p.id, cp.charcode, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, - p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username -FROM problems p -JOIN contest_problems cp ON p.id = cp.problem_id -JOIN users u ON u.id = p.writer_id -WHERE cp.contest_id = $1 ORDER BY charcode ASC` + id, charcode, writer_id, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at, writer_username +FROM contest_problemsets +WHERE contest_id = $1 ORDER BY charcode ASC` rows, err := p.conn.Query(ctx, query, contestID) if err != nil { @@ -128,20 +123,20 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m batch := &pgx.Batch{} - whereClauses := []string{"c.end_time >= now()"} + whereClauses := []string{"end_time >= now()"} queryArgs := []interface{}{limit, offset} countArgs := []interface{}{} paramIndex := 3 if filters.CreatorID != 0 { - whereClauses = append(whereClauses, fmt.Sprintf("c.creator_id = $%d", paramIndex)) + whereClauses = append(whereClauses, fmt.Sprintf("creator_id = $%d", paramIndex)) queryArgs = append(queryArgs, filters.CreatorID) countArgs = append(countArgs, filters.CreatorID) paramIndex++ } if filters.Title != "" { - whereClauses = append(whereClauses, fmt.Sprintf("LOWER(c.title) LIKE LOWER($%d)", paramIndex)) + whereClauses = append(whereClauses, fmt.Sprintf("LOWER(title) LIKE LOWER($%d)", paramIndex)) queryArgs = append(queryArgs, "%"+filters.Title+"%") countArgs = append(countArgs, "%"+filters.Title+"%") paramIndex++ @@ -151,32 +146,29 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT - c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants -FROM contests c -JOIN users u ON u.id = c.creator_id -LEFT JOIN entries e ON e.contest_id = c.id + id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, + allow_late_join, wallet_id, created_at, creator_username, participants +FROM contest_details %s -GROUP BY c.id, u.username -ORDER BY c.id ASC +ORDER BY id ASC LIMIT $1 OFFSET $2 `, whereClause) batch.Queue(query, queryArgs...) - countWhereClauses := []string{"contests.end_time >= now()"} + countWhereClauses := []string{"end_time >= now()"} if filters.CreatorID != 0 { - countWhereClauses = append(countWhereClauses, "contests.creator_id = $1") + countWhereClauses = append(countWhereClauses, "creator_id = $1") } if filters.Title != "" { countParamIndex := 1 if filters.CreatorID != 0 { countParamIndex = 2 } - countWhereClauses = append(countWhereClauses, fmt.Sprintf("LOWER(contests.title) LIKE LOWER($%d)", countParamIndex)) + countWhereClauses = append(countWhereClauses, fmt.Sprintf("LOWER(title) LIKE LOWER($%d)", countParamIndex)) } countWhereClause := strings.Join(countWhereClauses, " AND ") - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM contests WHERE %s", countWhereClause) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM contest_details WHERE %s", countWhereClause) if len(countArgs) > 0 { batch.Queue(countQuery, countArgs...) @@ -225,18 +217,15 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch := &pgx.Batch{} batch.Queue(` SELECT - c.id, c.creator_id, c.title, c.description, c.start_time, c.end_time, c.duration_mins, c.max_entries, - c.allow_late_join, c.wallet_id, c.created_at, u.username AS creator_username, COUNT(e.id) AS participants -FROM contests c -JOIN users u ON u.id = c.creator_id -LEFT JOIN entries e ON e.contest_id = c.id -WHERE c.creator_id = $1 -GROUP BY c.id, u.username -ORDER BY c.id ASC + id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, + allow_late_join, wallet_id, created_at, creator_username, participants +FROM contest_details +WHERE creator_id = $1 +ORDER BY id ASC LIMIT $2 OFFSET $3 `, creatorID, limit, offset) - batch.Queue(`SELECT COUNT(*) FROM contests WHERE creator_id = $1`, creatorID) + batch.Queue(`SELECT COUNT(*) FROM contest_details WHERE creator_id = $1`, creatorID) br := p.conn.SendBatch(ctx, batch) diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index ba79214..07c2e6f 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -63,12 +63,10 @@ func (p *Postgres) AssociateTestCases(ctx context.Context, problemID int, tcs [] func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { query := ` SELECT - p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, - p.memory_limit_mb, p.checker, p.created_at, cp.charcode, u.username AS writer_username -FROM problems p -JOIN contest_problems cp ON p.id = cp.problem_id -JOIN users u ON u.id = p.writer_id -WHERE cp.contest_id = $1 AND cp.charcode = $2` + id, writer_id, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at, charcode, writer_username +FROM contest_problemsets +WHERE contest_id = $1 AND charcode = $2` row := p.conn.QueryRow(ctx, query, contestID, charcode) @@ -84,12 +82,11 @@ WHERE cp.contest_id = $1 AND cp.charcode = $2` func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, error) { query := `SELECT - p.id, p.writer_id, p.title, p.statement, - p.difficulty, p.time_limit_ms, p.memory_limit_mb, p.checker, p.created_at, - u.username AS writer_username - FROM problems p - JOIN users u ON u.id = p.writer_id - WHERE p.id = $1` + id, writer_id, title, statement, + difficulty, time_limit_ms, memory_limit_mb, checker, created_at, + writer_username + FROM problem_details + WHERE id = $1` row := p.conn.QueryRow(ctx, query, problemID) @@ -147,10 +144,9 @@ func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models. func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { query := ` SELECT - p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, - p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username -FROM problems p -JOIN users u ON u.id = p.writer_id` + id, writer_id, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at, writer_username +FROM problem_details` rows, err := p.conn.Query(ctx, query) if err != nil { @@ -178,17 +174,16 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, off batch.Queue(` SELECT - p.id, p.writer_id, p.title, p.statement, p.difficulty, p.time_limit_ms, - p.memory_limit_mb, p.checker, p.created_at, u.username AS writer_username -FROM problems p -JOIN users u ON u.id = p.writer_id + id, writer_id, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at, writer_username +FROM problem_details WHERE writer_id = $1 -ORDER BY p.id ASC +ORDER BY id ASC LIMIT $2 OFFSET $3 `, writerID, limit, offset) batch.Queue(` - SELECT COUNT(*) FROM problems WHERE writer_id = $1 + SELECT COUNT(*) FROM problem_details WHERE writer_id = $1 `, writerID) br := p.conn.SendBatch(ctx, batch) diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index bc8f8e0..03541b8 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -43,14 +43,9 @@ func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int, problemID int) (string, error) { query := ` - SELECT - CASE - WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' - WHEN COUNT(*) > 0 THEN 'tried' - ELSE NULL - END AS status - FROM submissions s - WHERE s.entry_id = $1 AND s.problem_id = $2 + SELECT status + FROM problem_statuses + WHERE entry_id = $1 AND problem_id = $2 ` var status sql.NullString @@ -68,15 +63,10 @@ func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int, problemID func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int) (map[int]string, error) { query := ` SELECT - s.problem_id, - CASE - WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' - WHEN COUNT(*) > 0 THEN 'tried' - ELSE NULL - END AS status - FROM submissions s - WHERE s.entry_id = $1 - GROUP BY s.problem_id + problem_id, + status + FROM problem_statuses + WHERE entry_id = $1 ` rows, err := p.conn.Query(ctx, query, entryID) diff --git a/migrations/000001_init.down.sql b/migrations/000001_init.down.sql index 2542934..802fdaf 100644 --- a/migrations/000001_init.down.sql +++ b/migrations/000001_init.down.sql @@ -1,3 +1,7 @@ +DROP VIEW IF EXISTS contest_problemsets; +DROP VIEW IF EXISTS problem_statuses; +DROP VIEW IF EXISTS problem_details; +DROP VIEW IF EXISTS contest_details; DROP VIEW IF EXISTS leaderboard; DROP TABLE IF EXISTS testing_reports; diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index 3f1b622..8cf4033 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -133,3 +133,67 @@ LEFT JOIN ( ) s ON e.id = s.entry_id LEFT JOIN problems p ON s.problem_id = p.id GROUP BY e.contest_id, u.id, u.username; + +CREATE VIEW contest_details AS +SELECT + c.id, + c.creator_id, + c.title, + c.description, + c.start_time, + c.end_time, + c.duration_mins, + c.max_entries, + c.allow_late_join, + c.wallet_id, + c.created_at, + u.username AS creator_username, + COUNT(e.id) AS participants +FROM contests c +JOIN users u ON u.id = c.creator_id +LEFT JOIN entries e ON e.contest_id = c.id +GROUP BY c.id, u.username; + +CREATE VIEW problem_details AS +SELECT + p.id, + p.writer_id, + p.title, + p.statement, + p.difficulty, + p.time_limit_ms, + p.memory_limit_mb, + p.checker, + p.created_at, + u.username AS writer_username +FROM problems p +JOIN users u ON u.id = p.writer_id; + +CREATE VIEW problem_statuses AS +SELECT + s.entry_id, + s.problem_id, + CASE + WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' + WHEN COUNT(*) > 0 THEN 'tried' + END AS status +FROM submissions s +GROUP BY s.entry_id, s.problem_id; + +CREATE VIEW contest_problemsets AS +SELECT + cp.contest_id, + p.id, + cp.charcode, + p.writer_id, + p.title, + p.statement, + p.difficulty, + p.time_limit_ms, + p.memory_limit_mb, + p.checker, + p.created_at, + u.username AS writer_username +FROM problems p +JOIN contest_problems cp ON p.id = cp.problem_id +JOIN users u ON u.id = p.writer_id; From be0ba778abedb9aacd6ad9e117553d47d25551ee Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 16:21:22 +0400 Subject: [PATCH 17/56] chore: cleanup views, prizes --- internal/app/handler/contest.go | 14 ++++---- internal/app/handler/dto/request/request.go | 6 ++-- internal/app/handler/dto/response/response.go | 6 +++- internal/app/handler/problem.go | 2 +- internal/app/handler/submission.go | 2 +- internal/app/service/contest.go | 9 +++-- internal/storage/models/models.go | 27 +++++++------- .../repository/postgres/contest/contest.go | 32 ++++++++--------- .../repository/postgres/problem/problem.go | 35 ++++++++----------- .../postgres/submission/submission.go | 5 +++ migrations/000001_init.up.sql | 21 +++++------ 11 files changed, 85 insertions(+), 74 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index e240c83..3aec70a 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -17,7 +17,7 @@ func (h *Handler) CreateContest(c echo.Context) error { claims, _ := ExtractClaims(c) - var body request.CreateContestRequest + var body request.CreateContest if err := validate.Bind(c, &body); err != nil { return Error(http.StatusBadRequest, "invalid body: missing required fields") } @@ -83,7 +83,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { Username: contest.CreatorUsername, }, Problems: make([]response.ContestProblemListItem, n, n), - Participants: contest.Participants, + Participants: contest.ParticipantsCount, StartTime: contest.StartTime, EndTime: contest.EndTime, DurationMins: contest.DurationMins, @@ -91,8 +91,10 @@ func (h *Handler) GetContestByID(c echo.Context) error { AllowLateJoin: contest.AllowLateJoin, IsParticipant: details.IsParticipant, SubmissionDeadline: details.SubmissionDeadline, - PrizePot: details.PrizePot, - CreatedAt: contest.CreatedAt, + Prizes: response.Prizes{ + Nanos: details.PrizeNanosTON, + }, + CreatedAt: contest.CreatedAt, } for i := range n { @@ -150,7 +152,7 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { EndTime: contest.EndTime, DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, - Participants: contest.Participants, + Participants: contest.ParticipantsCount, CreatedAt: contest.CreatedAt, } items = append(items, item) @@ -212,7 +214,7 @@ func (h *Handler) GetContests(c echo.Context) error { EndTime: contest.EndTime, DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, - Participants: contest.Participants, + Participants: contest.ParticipantsCount, CreatedAt: contest.CreatedAt, } items = append(items, item) diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index ac3025b..848f1eb 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -16,7 +16,7 @@ type CreateSession struct { Password string `json:"password" required:"true"` } -type CreateContestRequest struct { +type CreateContest struct { Title string `json:"title" required:"true"` Description string `json:"description"` AwardType string `json:"award_type"` @@ -28,7 +28,7 @@ type CreateContestRequest struct { AllowLateJoin bool `json:"allow_late_join"` } -type CreateProblemRequest struct { +type CreateProblem struct { Title string `json:"title" required:"true"` Statement string `json:"statement" required:"true"` Difficulty string `json:"difficulty" required:"true"` @@ -38,7 +38,7 @@ type CreateProblemRequest struct { TestCases []models.TestCaseDTO `json:"test_cases"` } -type CreateSubmissionRequest struct { +type CreateSubmission struct { Code string `json:"code"` Language string `json:"language"` } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 7623c3c..0bd6d0c 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -56,10 +56,14 @@ type ContestDetailed struct { IsParticipant bool `json:"is_participant,omitempty"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` Problems []ContestProblemListItem `json:"problems"` - PrizePot uint64 `json:"prize_pot,omitempty"` // in TON nanos (1 TON = 1,000,000,000 nanos) + Prizes Prizes `json:"prizes"` CreatedAt time.Time `json:"created_at"` } +type Prizes struct { + Nanos uint64 `json:"ton_nanos"` +} + type ContestListItem struct { ID int `json:"id"` Creator User `json:"creator"` diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index a959477..696fec0 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -17,7 +17,7 @@ func (h *Handler) CreateProblem(c echo.Context) error { claims, _ := ExtractClaims(c) - var body request.CreateProblemRequest + var body request.CreateProblem if err := validate.Bind(c, &body); err != nil { return Error(http.StatusBadRequest, "invalid body: missing required fields") } diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index a09c5a9..8b0e05d 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -27,7 +27,7 @@ func (h *Handler) CreateSubmission(c echo.Context) error { charcode := c.Param("charcode") - var body request.CreateSubmissionRequest + var body request.CreateSubmission if err := validate.Bind(c, &body); err != nil { log.Debug("can't decode request body", sl.Err(err)) return Error(http.StatusBadRequest, "invalid body") diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 2b39772..0edad28 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "strings" "time" @@ -147,7 +148,7 @@ type ContestDetails struct { IsParticipant bool SubmissionDeadline *time.Time ProblemStatuses map[int]string - PrizePot uint64 + PrizeNanosTON uint64 } func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID int, authenticated bool) (*ContestDetails, error) { @@ -190,11 +191,15 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to parse wallet address: %w", op, err) } - details.PrizePot, err = s.ton.GetBalance(ctx, addr) + start := time.Now() + details.PrizeNanosTON, err = s.ton.GetBalance(ctx, addr) + end := time.Now() if err != nil { // TODO: maybe on this error, just return balance = 0 (?) return nil, fmt.Errorf("%s: failed to get wallet balance: %w", op, err) } + + slog.Info("fetching balance for wallet took", slog.Any("ms", end.Sub(start).Milliseconds())) } if !authenticated { diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 6e1a262..5b4fd12 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -27,19 +27,20 @@ type Role struct { } type Contest struct { - ID int `db:"id"` - CreatorID int `db:"creator_id"` - CreatorUsername string `db:"creator_username"` - Title string `db:"title"` - Description string `db:"description"` - StartTime time.Time `db:"start_time"` - EndTime time.Time `db:"end_time"` - DurationMins int `db:"duration_mins"` - MaxEntries int `db:"max_entries"` - AllowLateJoin bool `db:"allow_late_join"` - Participants int `db:"participants"` - WalletID *int `db:"wallet_id"` - CreatedAt time.Time `db:"created_at"` + ID int `db:"id"` + CreatorID int `db:"creator_id"` + CreatorUsername string `db:"creator_username"` + Title string `db:"title"` + Description string `db:"description"` + AwardType string `db:"award_type"` + StartTime time.Time `db:"start_time"` + EndTime time.Time `db:"end_time"` + DurationMins int `db:"duration_mins"` + MaxEntries int `db:"max_entries"` + AllowLateJoin bool `db:"allow_late_join"` + ParticipantsCount int `db:"participants"` + WalletID *int `db:"wallet_id"` + CreatedAt time.Time `db:"created_at"` } type ContestFilters struct { diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 1a45daa..f291152 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -69,14 +69,14 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, var contest models.Contest query := ` SELECT - id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, created_at, creator_username, participants + id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, + max_entries, allow_late_join, wallet_id, participants_count, created_at FROM contest_details WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( - &contest.ID, &contest.CreatorID, &contest.Title, &contest.Description, &contest.StartTime, + &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, - &contest.WalletID, &contest.CreatedAt, &contest.CreatorUsername, &contest.Participants) + &contest.WalletID, &contest.ParticipantsCount, &contest.CreatedAt) return contest, err } @@ -94,8 +94,8 @@ WHERE w.id = $1` func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { query := ` SELECT - id, charcode, writer_id, title, statement, difficulty, time_limit_ms, - memory_limit_mb, checker, created_at, writer_username + problem_id, charcode, writer_id, writer_username, title, statement, + difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM contest_problemsets WHERE contest_id = $1 ORDER BY charcode ASC` @@ -108,7 +108,7 @@ WHERE contest_id = $1 ORDER BY charcode ASC` var problems []models.Problem for rows.Next() { var problem models.Problem - if err := rows.Scan(&problem.ID, &problem.Charcode, &problem.WriterID, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, &problem.WriterUsername); err != nil { + if err := rows.Scan(&problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt); err != nil { return nil, err } problems = append(problems, problem) @@ -146,8 +146,8 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT - id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, created_at, creator_username, participants + id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, max_entries, + allow_late_join, wallet_id, participants_count, created_at FROM contest_details %s ORDER BY id ASC @@ -188,10 +188,9 @@ LIMIT $1 OFFSET $2 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.Title, &c.Description, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.CreatedAt, - &c.CreatorUsername, &c.Participants, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() @@ -217,8 +216,8 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch := &pgx.Batch{} batch.Queue(` SELECT - id, creator_id, title, description, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, created_at, creator_username, participants + id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, max_entries, + allow_late_join, wallet_id, participants_count, created_at FROM contest_details WHERE creator_id = $1 ORDER BY id ASC @@ -239,10 +238,9 @@ LIMIT $2 OFFSET $3 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.Title, &c.Description, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.CreatedAt, - &c.CreatorUsername, &c.Participants, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index 07c2e6f..07f18a5 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -63,8 +63,8 @@ func (p *Postgres) AssociateTestCases(ctx context.Context, problemID int, tcs [] func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { query := ` SELECT - id, writer_id, title, statement, difficulty, time_limit_ms, - memory_limit_mb, checker, created_at, charcode, writer_username + problem_id, charcode, writer_id, writer_username, title, statement, + difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM contest_problemsets WHERE contest_id = $1 AND charcode = $2` @@ -72,9 +72,8 @@ WHERE contest_id = $1 AND charcode = $2` var problem models.Problem err := row.Scan( - &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, + &problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, - &problem.Charcode, &problem.WriterUsername, ) return problem, err @@ -82,9 +81,8 @@ WHERE contest_id = $1 AND charcode = $2` func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, error) { query := `SELECT - id, writer_id, title, statement, - difficulty, time_limit_ms, memory_limit_mb, checker, created_at, - writer_username + id, writer_id, writer_username, title, statement, + difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM problem_details WHERE id = $1` @@ -92,9 +90,8 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, var problem models.Problem err := row.Scan( - &problem.ID, &problem.WriterID, &problem.Title, &problem.Statement, + &problem.ID, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, - &problem.WriterUsername, ) return problem, err @@ -144,8 +141,8 @@ func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models. func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { query := ` SELECT - id, writer_id, title, statement, difficulty, time_limit_ms, - memory_limit_mb, checker, created_at, writer_username + id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at FROM problem_details` rows, err := p.conn.Query(ctx, query) @@ -158,8 +155,8 @@ FROM problem_details` for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, - &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, &p.WriterUsername, + &p.ID, &p.WriterID, &p.WriterUsername, &p.Title, &p.Statement, &p.Difficulty, + &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, ); err != nil { return nil, err } @@ -174,17 +171,15 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, off batch.Queue(` SELECT - id, writer_id, title, statement, difficulty, time_limit_ms, - memory_limit_mb, checker, created_at, writer_username + id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, + memory_limit_mb, checker, created_at FROM problem_details WHERE writer_id = $1 ORDER BY id ASC LIMIT $2 OFFSET $3 `, writerID, limit, offset) - batch.Queue(` - SELECT COUNT(*) FROM problem_details WHERE writer_id = $1 - `, writerID) + batch.Queue(`SELECT COUNT(*) FROM problem_details WHERE writer_id = $1`, writerID) br := p.conn.SendBatch(ctx, batch) @@ -198,8 +193,8 @@ LIMIT $2 OFFSET $3 for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.WriterID, &p.Title, &p.Statement, &p.Difficulty, - &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, &p.WriterUsername, + &p.ID, &p.WriterID, &p.WriterUsername, &p.Title, &p.Statement, &p.Difficulty, + &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, ); err != nil { rows.Close() br.Close() diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index 03541b8..63993a8 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -3,8 +3,10 @@ package submission import ( "context" "database/sql" + "errors" "fmt" + "github.com/jackc/pgx/v5" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/internal/storage/repository/postgres" ) @@ -51,6 +53,9 @@ func (p *Postgres) GetProblemStatus(ctx context.Context, entryID int, problemID var status sql.NullString err := p.conn.QueryRow(ctx, query, entryID, problemID).Scan(&status) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } return "", fmt.Errorf("query failed: %w", err) } diff --git a/migrations/000001_init.up.sql b/migrations/000001_init.up.sql index 8cf4033..c205329 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_init.up.sql @@ -138,17 +138,18 @@ CREATE VIEW contest_details AS SELECT c.id, c.creator_id, + u.username AS creator_username, c.title, c.description, + c.award_type, c.start_time, c.end_time, c.duration_mins, c.max_entries, c.allow_late_join, c.wallet_id, - c.created_at, - u.username AS creator_username, - COUNT(e.id) AS participants + COUNT(e.id) AS participants_count, + c.created_at FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id @@ -158,21 +159,21 @@ CREATE VIEW problem_details AS SELECT p.id, p.writer_id, + u.username AS writer_username, p.title, p.statement, p.difficulty, p.time_limit_ms, p.memory_limit_mb, p.checker, - p.created_at, - u.username AS writer_username + p.created_at FROM problems p JOIN users u ON u.id = p.writer_id; CREATE VIEW problem_statuses AS SELECT - s.entry_id, s.problem_id, + s.entry_id, CASE WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' WHEN COUNT(*) > 0 THEN 'tried' @@ -182,18 +183,18 @@ GROUP BY s.entry_id, s.problem_id; CREATE VIEW contest_problemsets AS SELECT - cp.contest_id, - p.id, + p.id AS problem_id, cp.charcode, + cp.contest_id, p.writer_id, + u.username AS writer_username, p.title, p.statement, p.difficulty, p.time_limit_ms, p.memory_limit_mb, p.checker, - p.created_at, - u.username AS writer_username + p.created_at FROM problems p JOIN contest_problems cp ON p.id = cp.problem_id JOIN users u ON u.id = p.writer_id; From 33a5bcee0be1a5e16c7c073e880a7d62e1524f5c Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 16:26:59 +0400 Subject: [PATCH 18/56] migrations: split creating view out of tables --- ...down.sql => 000001_create_tables.down.sql} | 6 -- ...nit.up.sql => 000001_create_tables.up.sql} | 88 ------------------ migrations/000002_create_views.down.sql | 5 + migrations/000002_create_views.up.sql | 91 +++++++++++++++++++ 4 files changed, 96 insertions(+), 94 deletions(-) rename migrations/{000001_init.down.sql => 000001_create_tables.down.sql} (66%) rename migrations/{000001_init.up.sql => 000001_create_tables.up.sql} (67%) create mode 100644 migrations/000002_create_views.down.sql create mode 100644 migrations/000002_create_views.up.sql diff --git a/migrations/000001_init.down.sql b/migrations/000001_create_tables.down.sql similarity index 66% rename from migrations/000001_init.down.sql rename to migrations/000001_create_tables.down.sql index 802fdaf..699c8c6 100644 --- a/migrations/000001_init.down.sql +++ b/migrations/000001_create_tables.down.sql @@ -1,9 +1,3 @@ -DROP VIEW IF EXISTS contest_problemsets; -DROP VIEW IF EXISTS problem_statuses; -DROP VIEW IF EXISTS problem_details; -DROP VIEW IF EXISTS contest_details; -DROP VIEW IF EXISTS leaderboard; - DROP TABLE IF EXISTS testing_reports; DROP TABLE IF EXISTS submissions; DROP TABLE IF EXISTS entries; diff --git a/migrations/000001_init.up.sql b/migrations/000001_create_tables.up.sql similarity index 67% rename from migrations/000001_init.up.sql rename to migrations/000001_create_tables.up.sql index c205329..e2241dc 100644 --- a/migrations/000001_init.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -110,91 +110,3 @@ CREATE TABLE testing_reports ( stderr TEXT DEFAULT '' NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); - -CREATE VIEW leaderboard AS -SELECT - e.contest_id, - u.id AS user_id, - u.username, - COALESCE(SUM( - CASE - WHEN p.difficulty = 'easy' THEN 1 - WHEN p.difficulty = 'mid' THEN 3 - WHEN p.difficulty = 'hard' THEN 5 - ELSE 0 - END - ), 0) AS points -FROM users u -JOIN entries e ON u.id = e.user_id -LEFT JOIN ( - SELECT DISTINCT entry_id, problem_id - FROM submissions - WHERE verdict = 'ok' -) s ON e.id = s.entry_id -LEFT JOIN problems p ON s.problem_id = p.id -GROUP BY e.contest_id, u.id, u.username; - -CREATE VIEW contest_details AS -SELECT - c.id, - c.creator_id, - u.username AS creator_username, - c.title, - c.description, - c.award_type, - c.start_time, - c.end_time, - c.duration_mins, - c.max_entries, - c.allow_late_join, - c.wallet_id, - COUNT(e.id) AS participants_count, - c.created_at -FROM contests c -JOIN users u ON u.id = c.creator_id -LEFT JOIN entries e ON e.contest_id = c.id -GROUP BY c.id, u.username; - -CREATE VIEW problem_details AS -SELECT - p.id, - p.writer_id, - u.username AS writer_username, - p.title, - p.statement, - p.difficulty, - p.time_limit_ms, - p.memory_limit_mb, - p.checker, - p.created_at -FROM problems p -JOIN users u ON u.id = p.writer_id; - -CREATE VIEW problem_statuses AS -SELECT - s.problem_id, - s.entry_id, - CASE - WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' - WHEN COUNT(*) > 0 THEN 'tried' - END AS status -FROM submissions s -GROUP BY s.entry_id, s.problem_id; - -CREATE VIEW contest_problemsets AS -SELECT - p.id AS problem_id, - cp.charcode, - cp.contest_id, - p.writer_id, - u.username AS writer_username, - p.title, - p.statement, - p.difficulty, - p.time_limit_ms, - p.memory_limit_mb, - p.checker, - p.created_at -FROM problems p -JOIN contest_problems cp ON p.id = cp.problem_id -JOIN users u ON u.id = p.writer_id; diff --git a/migrations/000002_create_views.down.sql b/migrations/000002_create_views.down.sql new file mode 100644 index 0000000..63645d5 --- /dev/null +++ b/migrations/000002_create_views.down.sql @@ -0,0 +1,5 @@ +DROP VIEW IF EXISTS contest_problemsets; +DROP VIEW IF EXISTS problem_statuses; +DROP VIEW IF EXISTS problem_details; +DROP VIEW IF EXISTS contest_details; +DROP VIEW IF EXISTS leaderboard; diff --git a/migrations/000002_create_views.up.sql b/migrations/000002_create_views.up.sql new file mode 100644 index 0000000..61912c4 --- /dev/null +++ b/migrations/000002_create_views.up.sql @@ -0,0 +1,91 @@ +CREATE VIEW leaderboard AS +SELECT + e.contest_id, + u.id AS user_id, + u.username, + COALESCE(SUM( + CASE + WHEN p.difficulty = 'easy' THEN 1 + WHEN p.difficulty = 'mid' THEN 3 + WHEN p.difficulty = 'hard' THEN 5 + ELSE 0 + END + ), 0) AS points +FROM users u +JOIN entries e ON u.id = e.user_id +LEFT JOIN ( + SELECT DISTINCT entry_id, problem_id + FROM submissions + WHERE verdict = 'ok' +) s ON e.id = s.entry_id +LEFT JOIN problems p ON s.problem_id = p.id +GROUP BY e.contest_id, u.id, u.username; + + +CREATE VIEW contest_details AS +SELECT + c.id, + c.creator_id, + u.username AS creator_username, + c.title, + c.description, + c.award_type, + c.start_time, + c.end_time, + c.duration_mins, + c.max_entries, + c.allow_late_join, + c.wallet_id, + COUNT(e.id) AS participants_count, + c.created_at +FROM contests c +JOIN users u ON u.id = c.creator_id +LEFT JOIN entries e ON e.contest_id = c.id +GROUP BY c.id, u.username; + + +CREATE VIEW problem_details AS +SELECT + p.id, + p.writer_id, + u.username AS writer_username, + p.title, + p.statement, + p.difficulty, + p.time_limit_ms, + p.memory_limit_mb, + p.checker, + p.created_at +FROM problems p +JOIN users u ON u.id = p.writer_id; + + +CREATE VIEW problem_statuses AS +SELECT + s.problem_id, + s.entry_id, + CASE + WHEN COUNT(*) FILTER (WHERE s.verdict = 'ok') > 0 THEN 'accepted' + WHEN COUNT(*) > 0 THEN 'tried' + END AS status +FROM submissions s +GROUP BY s.entry_id, s.problem_id; + + +CREATE VIEW contest_problemsets AS +SELECT + p.id AS problem_id, + cp.charcode, + cp.contest_id, + p.writer_id, + u.username AS writer_username, + p.title, + p.statement, + p.difficulty, + p.time_limit_ms, + p.memory_limit_mb, + p.checker, + p.created_at +FROM problems p +JOIN contest_problems cp ON p.id = cp.problem_id +JOIN users u ON u.id = p.writer_id; From 9016c278ca166b3fafc2c53e240985e227dc181a Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 16:46:29 +0400 Subject: [PATCH 19/56] chore: Add submission_details view --- internal/app/router/router.go | 1 - internal/storage/models/models.go | 3 ++ .../postgres/submission/submission.go | 36 +++++++++++++------ migrations/000002_create_views.down.sql | 1 + migrations/000002_create_views.up.sql | 18 ++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/internal/app/router/router.go b/internal/app/router/router.go index bfa6b30..8faa599 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -83,7 +83,6 @@ func (r *Router) InitRoutes() *echo.Echo { api.POST("/account", r.handler.CreateAccount) api.POST("/session", r.handler.CreateSession) - // TODO: Migrate to /api/contests with query parameters // DONE: make this endpoints as filter to general endpoint, like: // GET /contests?creator_id=69 // GET /problems?writer_id=420 diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 5b4fd12..9df92a2 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -100,6 +100,9 @@ type Submission struct { ID int `db:"id"` EntryID int `db:"entry_id"` ProblemID int `db:"problem_id"` + ContestID int `db:"contest_id"` + UserID int `db:"user_id"` + Username string `db:"username"` Status string `db:"status"` Verdict string `db:"verdict"` Code string `db:"code"` diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index 63993a8..9e400a7 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -24,15 +24,27 @@ func New(conn postgres.Transactor) *Postgres { } func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code string, language string) (models.Submission, error) { - query := `INSERT INTO submissions (entry_id, problem_id, code, language) + var submissionID int + insertQuery := `INSERT INTO submissions (entry_id, problem_id, code, language) VALUES ($1, $2, $3, $4) - RETURNING id, entry_id, problem_id, status, verdict, code, language, created_at` + RETURNING id` + + err := p.conn.QueryRow(ctx, insertQuery, entryID, problemID, code, language).Scan(&submissionID) + if err != nil { + return models.Submission{}, fmt.Errorf("insert failed: %w", err) + } + + selectQuery := `SELECT id, entry_id, contest_id, problem_id, user_id, username, status, verdict, code, language, created_at + FROM submission_details WHERE id = $1` var submission models.Submission - err := p.conn.QueryRow(ctx, query, entryID, problemID, code, language).Scan( + err = p.conn.QueryRow(ctx, selectQuery, submissionID).Scan( &submission.ID, &submission.EntryID, + &submission.ContestID, &submission.ProblemID, + &submission.UserID, + &submission.Username, &submission.Status, &submission.Verdict, &submission.Code, @@ -105,14 +117,17 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int) (map[int } func (p *Postgres) GetByID(ctx context.Context, submissionID int) (models.Submission, error) { - query := `SELECT s.id, s.entry_id, s.problem_id, s.status, s.verdict, s.code, s.language, s.created_at - FROM submissions s WHERE s.id = $1` + query := `SELECT id, entry_id, contest_id, problem_id, user_id, username, status, verdict, code, language, created_at + FROM submission_details WHERE id = $1` var s models.Submission err := p.conn.QueryRow(ctx, query, submissionID).Scan( &s.ID, &s.EntryID, + &s.ContestID, &s.ProblemID, + &s.UserID, + &s.Username, &s.Status, &s.Verdict, &s.Code, @@ -129,11 +144,9 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int, charcode stri } query := ` - SELECT s.id, s.entry_id, s.problem_id, s.status, s.verdict, s.code, s.language, s.created_at, COUNT(*) OVER() as total_count - FROM submissions s - JOIN problems p ON p.id = s.problem_id - JOIN entries e ON s.entry_id = e.id - JOIN contest_problems cp ON cp.contest_id = e.contest_id AND cp.problem_id = s.problem_id + SELECT s.id, s.entry_id, s.contest_id, s.problem_id, s.user_id, s.username, s.status, s.verdict, s.code, s.language, s.created_at, COUNT(*) OVER() as total_count + FROM submission_details s + JOIN contest_problems cp ON cp.contest_id = s.contest_id AND cp.problem_id = s.problem_id WHERE s.entry_id = $1 AND cp.charcode = $2 ORDER BY s.created_at DESC LIMIT $3 OFFSET $4` @@ -150,7 +163,10 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int, charcode stri if err := rows.Scan( &s.ID, &s.EntryID, + &s.ContestID, &s.ProblemID, + &s.UserID, + &s.Username, &s.Status, &s.Verdict, &s.Code, diff --git a/migrations/000002_create_views.down.sql b/migrations/000002_create_views.down.sql index 63645d5..406c468 100644 --- a/migrations/000002_create_views.down.sql +++ b/migrations/000002_create_views.down.sql @@ -1,3 +1,4 @@ +DROP VIEW IF EXISTS submission_details; DROP VIEW IF EXISTS contest_problemsets; DROP VIEW IF EXISTS problem_statuses; DROP VIEW IF EXISTS problem_details; diff --git a/migrations/000002_create_views.up.sql b/migrations/000002_create_views.up.sql index 61912c4..bdfb4a1 100644 --- a/migrations/000002_create_views.up.sql +++ b/migrations/000002_create_views.up.sql @@ -89,3 +89,21 @@ SELECT FROM problems p JOIN contest_problems cp ON p.id = cp.problem_id JOIN users u ON u.id = p.writer_id; + + +CREATE VIEW submission_details AS +SELECT + s.id, + s.entry_id, + e.contest_id, + s.problem_id, + e.user_id, + u.username, + s.status, + s.verdict, + s.code, + s.language, + s.created_at +FROM submissions s +JOIN entries e ON s.entry_id = e.id +JOIN users u ON e.user_id = u.id; From f8d891a14ff5784d38335d74e6527c368f0e61cb Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 17:04:41 +0400 Subject: [PATCH 20/56] chore: add `award_type` to contest responses --- internal/app/handler/contest.go | 3 +++ internal/app/handler/dto/response/response.go | 2 ++ internal/app/service/contest.go | 5 +++-- internal/storage/models/award/award.go | 7 +++++++ migrations/000001_create_tables.up.sql | 8 ++------ 5 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 internal/storage/models/award/award.go diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 3aec70a..5801599 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -78,6 +78,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { ID: contest.ID, Title: contest.Title, Description: contest.Description, + AwardType: contest.AwardType, Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, @@ -148,6 +149,7 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { Username: contest.CreatorUsername, }, Title: contest.Title, + AwardType: contest.AwardType, StartTime: contest.StartTime, EndTime: contest.EndTime, DurationMins: contest.DurationMins, @@ -210,6 +212,7 @@ func (h *Handler) GetContests(c echo.Context) error { Username: contest.CreatorUsername, }, Title: contest.Title, + AwardType: contest.AwardType, StartTime: contest.StartTime, EndTime: contest.EndTime, DurationMins: contest.DurationMins, diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 0bd6d0c..958381b 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -46,6 +46,7 @@ type ContestDetailed struct { ID int `json:"id"` Title string `json:"title"` Description string `json:"description"` + AwardType string `json:"award_type"` Creator User `json:"creator"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` @@ -68,6 +69,7 @@ type ContestListItem struct { ID int `json:"id"` Creator User `json:"creator"` Title string `json:"title"` + AwardType string `json:"award_type"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` DurationMins int `json:"duration_mins"` diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 0edad28..761005d 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/models/award" "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ton" "github.com/xssnick/tonutils-go/address" @@ -79,7 +80,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest // NOTE: if award type is not `paid_entry` or `sponsored` - use `no_prize` by default var contestID int - if params.AwardType == "paid_entry" || params.AwardType == "sponsored" { + if params.AwardType == award.Pool || params.AwardType == award.Sponsored { w, err := s.ton.CreateWallet() if err != nil { return 0, err @@ -125,7 +126,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest params.UserID, params.Title, params.Description, - "no_award", + award.No, params.StartTime, params.EndTime, params.DurationMins, diff --git a/internal/storage/models/award/award.go b/internal/storage/models/award/award.go new file mode 100644 index 0000000..058a186 --- /dev/null +++ b/internal/storage/models/award/award.go @@ -0,0 +1,7 @@ +package award + +const ( + No = "free" + Sponsored = "sponsored" + Pool = "entry_pool" +) diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_create_tables.up.sql index e2241dc..6a9afba 100644 --- a/migrations/000001_create_tables.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -21,11 +21,7 @@ CREATE TABLE users ( created_at TIMESTAMP DEFAULT now() NOT NULL ); -CREATE TYPE contest_award_type AS ENUM ( - 'no_award', - 'paid_entry', - 'sponsored' -); +CREATE TYPE award_type AS ENUM ('no', 'pool', 'sponsored'); -- TODO: add unique index on `contests.wallet_id` -- TODO: encrypt the mnemonic before saving @@ -41,7 +37,7 @@ CREATE TABLE contests ( creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(64) NOT NULL, description VARCHAR(300) DEFAULT '' NOT NULL, - award_type contest_award_type NOT NULL, + award_type award_type NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), From 330fff459a850c149c21301d3d98e2318a220d9f Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 17:22:31 +0400 Subject: [PATCH 21/56] chore: add enum difficulty --- migrations/000001_create_tables.down.sql | 3 ++- migrations/000001_create_tables.up.sql | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/migrations/000001_create_tables.down.sql b/migrations/000001_create_tables.down.sql index 699c8c6..75dd3ba 100644 --- a/migrations/000001_create_tables.down.sql +++ b/migrations/000001_create_tables.down.sql @@ -9,4 +9,5 @@ DROP TABLE IF EXISTS wallets; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS roles; -DROP TYPE IF EXISTS contest_award_type; +DROP TYPE IF EXISTS award_type; +DROP TYPE IF EXISTS difficulty; diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_create_tables.up.sql index 6a9afba..7a6743f 100644 --- a/migrations/000001_create_tables.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -21,8 +21,6 @@ CREATE TABLE users ( created_at TIMESTAMP DEFAULT now() NOT NULL ); -CREATE TYPE award_type AS ENUM ('no', 'pool', 'sponsored'); - -- TODO: add unique index on `contests.wallet_id` -- TODO: encrypt the mnemonic before saving CREATE TABLE wallets ( @@ -32,6 +30,8 @@ CREATE TABLE wallets ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE TYPE award_type AS ENUM ('no', 'pool', 'sponsored'); + CREATE TABLE contests ( id SERIAL PRIMARY KEY, creator_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -47,12 +47,14 @@ CREATE TABLE contests ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE TYPE difficulty AS ENUM ('easy', 'mid', 'hard'); + CREATE TABLE problems ( id SERIAL PRIMARY KEY, writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(64) NOT NULL, statement TEXT NOT NULL, - difficulty VARCHAR(10) NOT NULL CHECK (difficulty IN ('easy', 'mid', 'hard')), + difficulty difficulty NOT NULL, time_limit_ms INTEGER DEFAULT 2000 NOT NULL CHECK (time_limit_ms >= 0), memory_limit_mb INTEGER DEFAULT 128 NOT NULL CHECK (memory_limit_mb >= 0), checker VARCHAR(10) NOT NULL DEFAULT 'tokens', From 7c132a0c80efd0ccdcde48d0235a5262b3536045 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 18:14:41 +0400 Subject: [PATCH 22/56] chore: check txs to pay the entry --- internal/app/handler/dto/response/response.go | 8 ++ internal/app/handler/entry.go | 32 ++++++++ internal/app/router/router.go | 1 + internal/app/service/entry.go | 78 ++++++++++++++++++- internal/app/service/errors.go | 1 + internal/app/service/service.go | 3 +- internal/storage/models/award/award.go | 4 +- internal/storage/models/models.go | 2 + .../repository/postgres/entry/entry.go | 9 ++- .../storage/repository/postgres/user/user.go | 9 ++- internal/storage/repository/repository.go | 1 + migrations/000001_create_tables.up.sql | 2 + 12 files changed, 141 insertions(+), 9 deletions(-) diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 958381b..2dea3fd 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -162,3 +162,11 @@ type TC struct { Input string `json:"input"` Output string `json:"output"` } + +type Entry struct { + ID int `json:"id"` + ContestID int `json:"contest_id"` + UserID int `json:"user_id"` + IsPaid bool `json:"is_paid"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index d530c29..aa89d85 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/labstack/echo/v4" + "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/app/service" ) @@ -36,3 +37,34 @@ func (h *Handler) CreateEntry(c echo.Context) error { return c.NoContent(http.StatusCreated) } + +func (h *Handler) GetEntry(c echo.Context) error { + ctx := c.Request().Context() + + claims, _ := ExtractClaims(c) + + contestID, ok := ExtractParamInt(c, "cid") + if !ok { + return Error(http.StatusBadRequest, "contest ID should be an integer") + } + + entry, err := h.service.Entry.GetEntry(ctx, contestID, claims.UserID) + if err != nil { + switch { + case errors.Is(err, service.ErrEntryNotFound): + return Error(http.StatusNotFound, "entry not found") + case errors.Is(err, service.ErrContestNotFound): + return Error(http.StatusNotFound, "contest not found") + default: + return err + } + } + + return c.JSON(http.StatusOK, response.Entry{ + ID: entry.ID, + ContestID: entry.ContestID, + UserID: entry.UserID, + IsPaid: entry.IsPaid, + CreatedAt: entry.CreatedAt, + }) +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 8faa599..0a8d0a4 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -98,6 +98,7 @@ func (r *Router) InitRoutes() *echo.Echo { api.GET("/contests/:cid", r.handler.GetContestByID, r.handler.TryIdentify()) api.POST("/contests/:cid/entry", r.handler.CreateEntry, r.handler.MustIdentify()) + api.GET("/contests/:cid/entry", r.handler.GetEntry, r.handler.MustIdentify()) api.GET("/contests/:cid/leaderboard", r.handler.GetLeaderboard) api.GET("/contests/:cid/problems/:charcode", r.handler.GetContestProblem, r.handler.MustIdentify()) diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index 194c0cf..a3a90cb 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -4,19 +4,27 @@ import ( "context" "errors" "fmt" + "math/big" "time" "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/models/award" "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" ) type EntryService struct { repo *repository.Repository + ton *ton.Client } -func NewEntryService(repo *repository.Repository) *EntryService { +func NewEntryService(repo *repository.Repository, tc *ton.Client) *EntryService { return &EntryService{ repo: repo, + ton: tc, } } @@ -60,3 +68,71 @@ func (s *EntryService) CreateEntry(ctx context.Context, contestID int, userID in return nil } + +func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) (models.Entry, error) { + op := "service.EntryService.GetEntry" + + entry, err := s.repo.Entry.Get(ctx, contestID, userID) + if errors.Is(err, pgx.ErrNoRows) { + return models.Entry{}, ErrEntryNotFound + } + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to get entry: %w", op, err) + } + + if entry.IsPaid { + return entry, nil + } + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + if contest.AwardType != award.Pool { + return entry, nil + } + + user, err := s.repo.User.GetByID(ctx, userID) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to get user: %w", op, err) + } + + if user.Address == nil || *user.Address == "" { + return entry, nil + } + + if contest.WalletID == nil { + return entry, nil + } + + wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) + } + + from, err := address.ParseAddr(*user.Address) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to parse sender address: %w", op, err) + } + + to, err := address.ParseAddr(wallet.Address) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to parse recepient address: %w", op, err) + } + + // TODO: unhardcode, move to contest settings + amount := tlb.FromNanoTON(big.NewInt(500000000)) + + exists := s.ton.LookupTx(ctx, from, to, amount) + + if exists { + err = s.repo.Entry.MarkAsPaid(ctx, entry.ID) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) + } + entry.IsPaid = true + } + + return entry, nil +} diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index 42eea3a..e59529b 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -16,6 +16,7 @@ var ( ErrMaxSlotsReached = errors.New("max slots limit reached") ErrApplicationTimeOver = errors.New("application time is over") ErrEntryAlreadyExists = errors.New("user already has entry for this contest") + ErrEntryNotFound = errors.New("entry not found") // problem ErrUserBanned = errors.New("you are banned from creating problems") diff --git a/internal/app/service/service.go b/internal/app/service/service.go index d5d2779..a27a6e1 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -17,9 +17,8 @@ type Service struct { func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client) *Service { return &Service{ - // TODO: pass only salt and signature key, not entire config Account: NewAccountService(cfg, repo), - Entry: NewEntryService(repo), + Entry: NewEntryService(repo, tc), Submission: NewSubmissionService(repo, broker), Problem: NewProblemService(repo), Contest: NewContestService(repo, tc), diff --git a/internal/storage/models/award/award.go b/internal/storage/models/award/award.go index 058a186..2862af0 100644 --- a/internal/storage/models/award/award.go +++ b/internal/storage/models/award/award.go @@ -1,7 +1,7 @@ package award const ( - No = "free" + No = "no" Sponsored = "sponsored" - Pool = "entry_pool" + Pool = "pool" ) diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 9df92a2..f80ae12 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -14,6 +14,7 @@ type User struct { Username string `db:"username"` PasswordHash string `db:"password_hash"` RoleID int `db:"role_id"` + Address *string `db:"address"` CreatedAt time.Time `db:"created_at"` } @@ -93,6 +94,7 @@ type Entry struct { ID int `db:"id"` ContestID int `db:"contest_id"` UserID int `db:"user_id"` + IsPaid bool `db:"is_paid"` CreatedAt time.Time `db:"created_at"` } diff --git a/internal/storage/repository/postgres/entry/entry.go b/internal/storage/repository/postgres/entry/entry.go index 5de4e78..452ba33 100644 --- a/internal/storage/repository/postgres/entry/entry.go +++ b/internal/storage/repository/postgres/entry/entry.go @@ -27,7 +27,7 @@ func (p *Postgres) Create(ctx context.Context, contestID int, userID int) (int, } func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) { - query := `SELECT id, contest_id, user_id, created_at FROM entries + query := `SELECT id, contest_id, user_id, is_paid, created_at FROM entries WHERE contest_id = $1 AND user_id = $2` var entry models.Entry @@ -35,6 +35,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E &entry.ID, &entry.ContestID, &entry.UserID, + &entry.IsPaid, &entry.CreatedAt, ) if err != nil { @@ -42,3 +43,9 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E } return entry, nil } + +func (p *Postgres) MarkAsPaid(ctx context.Context, entryID int) error { + query := `UPDATE entries SET is_paid = true WHERE id = $1` + _, err := p.conn.Exec(ctx, query, entryID) + return err +} diff --git a/internal/storage/repository/postgres/user/user.go b/internal/storage/repository/postgres/user/user.go index 98bd69b..99e06b2 100644 --- a/internal/storage/repository/postgres/user/user.go +++ b/internal/storage/repository/postgres/user/user.go @@ -18,12 +18,13 @@ func New(conn postgres.Transactor) *Postgres { func (p *Postgres) GetByCredentials(ctx context.Context, username string, passwordHash string) (models.User, error) { var user models.User - query := `SELECT id, username, password_hash, role_id, created_at FROM users WHERE username = $1 AND password_hash = $2` + query := `SELECT id, username, password_hash, role_id, address, created_at FROM users WHERE username = $1 AND password_hash = $2` err := p.conn.QueryRow(ctx, query, username, passwordHash).Scan( &user.ID, &user.Username, &user.PasswordHash, &user.RoleID, + &user.Address, &user.CreatedAt, ) return user, err @@ -35,7 +36,7 @@ func (p *Postgres) Create(ctx context.Context, username string, passwordHash str query := ` INSERT INTO users (username, password_hash, role_id) VALUES ($1, $2, (SELECT id FROM roles WHERE is_default = true LIMIT 1)) - RETURNING id, username, password_hash, role_id, created_at + RETURNING id, username, password_hash, role_id, address, created_at ` err := p.conn.QueryRow(ctx, query, username, passwordHash).Scan( @@ -43,6 +44,7 @@ func (p *Postgres) Create(ctx context.Context, username string, passwordHash str &user.Username, &user.PasswordHash, &user.RoleID, + &user.Address, &user.CreatedAt, ) return user, err @@ -63,12 +65,13 @@ func (p *Postgres) Exists(ctx context.Context, username string) (bool, error) { func (p *Postgres) GetByID(ctx context.Context, id int) (models.User, error) { var user models.User - query := `SELECT id, username, password_hash, role_id, created_at FROM users WHERE id = $1` + query := `SELECT id, username, password_hash, role_id, address, created_at FROM users WHERE id = $1` err := p.conn.QueryRow(ctx, query, id).Scan( &user.ID, &user.Username, &user.PasswordHash, &user.RoleID, + &user.Address, &user.CreatedAt, ) return user, err diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 148d689..504695e 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -98,6 +98,7 @@ type Problem interface { type Entry interface { Create(ctx context.Context, contestID int, userID int) (int, error) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) + MarkAsPaid(ctx context.Context, entryID int) error } type Submission interface { diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_create_tables.up.sql index 7a6743f..799f047 100644 --- a/migrations/000001_create_tables.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -18,6 +18,7 @@ CREATE TABLE users ( username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, + address VARCHAR(100), created_at TIMESTAMP DEFAULT now() NOT NULL ); @@ -83,6 +84,7 @@ CREATE TABLE entries ( id SERIAL PRIMARY KEY, contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_paid BOOLEAN DEFAULT false NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL, UNIQUE (contest_id, user_id) ); From fe87802130584ed23f50b916755295537fc1a17f Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 18:40:57 +0400 Subject: [PATCH 23/56] chore: add tx hash, and entry price for the contest --- internal/app/handler/contest.go | 43 ++++++++++--------- internal/app/handler/dto/response/response.go | 23 +++++----- internal/app/handler/entry.go | 1 + internal/app/service/contest.go | 8 +--- internal/app/service/entry.go | 20 +++++---- internal/storage/models/models.go | 30 +++++++------ .../repository/postgres/contest/contest.go | 12 +++--- .../repository/postgres/entry/entry.go | 9 ++-- internal/storage/repository/repository.go | 2 +- migrations/000001_create_tables.up.sql | 2 + migrations/000002_create_views.up.sql | 1 + pkg/ton/ton.go | 26 +++++------ 12 files changed, 93 insertions(+), 84 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 5801599..478b146 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -75,10 +75,11 @@ func (h *Handler) GetContestByID(c echo.Context) error { contest := details.Contest n := len(details.Problems) cdetailed := response.ContestDetailed{ - ID: contest.ID, - Title: contest.Title, - Description: contest.Description, - AwardType: contest.AwardType, + ID: contest.ID, + Title: contest.Title, + Description: contest.Description, + AwardType: contest.AwardType, + EntryPriceTonNanos: contest.EntryPriceTonNanos, Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, @@ -148,14 +149,15 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { ID: contest.CreatorID, Username: contest.CreatorUsername, }, - Title: contest.Title, - AwardType: contest.AwardType, - StartTime: contest.StartTime, - EndTime: contest.EndTime, - DurationMins: contest.DurationMins, - MaxEntries: contest.MaxEntries, - Participants: contest.ParticipantsCount, - CreatedAt: contest.CreatedAt, + Title: contest.Title, + AwardType: contest.AwardType, + EntryPriceTonNanos: contest.EntryPriceTonNanos, + StartTime: contest.StartTime, + EndTime: contest.EndTime, + DurationMins: contest.DurationMins, + MaxEntries: contest.MaxEntries, + Participants: contest.ParticipantsCount, + CreatedAt: contest.CreatedAt, } items = append(items, item) } @@ -211,14 +213,15 @@ func (h *Handler) GetContests(c echo.Context) error { ID: contest.CreatorID, Username: contest.CreatorUsername, }, - Title: contest.Title, - AwardType: contest.AwardType, - StartTime: contest.StartTime, - EndTime: contest.EndTime, - DurationMins: contest.DurationMins, - MaxEntries: contest.MaxEntries, - Participants: contest.ParticipantsCount, - CreatedAt: contest.CreatedAt, + Title: contest.Title, + AwardType: contest.AwardType, + EntryPriceTonNanos: contest.EntryPriceTonNanos, + StartTime: contest.StartTime, + EndTime: contest.EndTime, + DurationMins: contest.DurationMins, + MaxEntries: contest.MaxEntries, + Participants: contest.ParticipantsCount, + CreatedAt: contest.CreatedAt, } items = append(items, item) } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 2dea3fd..3d64ab5 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -47,6 +47,7 @@ type ContestDetailed struct { Title string `json:"title"` Description string `json:"description"` AwardType string `json:"award_type"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos,omitempty"` Creator User `json:"creator"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` @@ -66,16 +67,17 @@ type Prizes struct { } type ContestListItem struct { - ID int `json:"id"` - Creator User `json:"creator"` - Title string `json:"title"` - AwardType string `json:"award_type"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - DurationMins int `json:"duration_mins"` - MaxEntries int `json:"max_entries,omitempty"` - Participants int `json:"participants"` - CreatedAt time.Time `json:"created_at"` + ID int `json:"id"` + Creator User `json:"creator"` + Title string `json:"title"` + AwardType string `json:"award_type"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + DurationMins int `json:"duration_mins"` + MaxEntries int `json:"max_entries,omitempty"` + Participants int `json:"participants"` + CreatedAt time.Time `json:"created_at"` } type Submission struct { @@ -168,5 +170,6 @@ type Entry struct { ContestID int `json:"contest_id"` UserID int `json:"user_id"` IsPaid bool `json:"is_paid"` + TxHash string `json:"tx_hash,omitempty"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index aa89d85..a35286e 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -65,6 +65,7 @@ func (h *Handler) GetEntry(c echo.Context) error { ContestID: entry.ContestID, UserID: entry.UserID, IsPaid: entry.IsPaid, + TxHash: entry.TxHash, CreatedAt: entry.CreatedAt, }) } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 761005d..16e72b4 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log/slog" "strings" "time" @@ -180,8 +179,7 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user Problems: problems, } - // TODO: can additionally check for contest.award_type - if contest.WalletID != nil { + if contest.WalletID != nil && (contest.AwardType == award.Pool || contest.AwardType == award.Sponsored) { wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) if err != nil { return nil, fmt.Errorf("%s: failed to get wallet: %w", op, err) @@ -192,15 +190,11 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to parse wallet address: %w", op, err) } - start := time.Now() details.PrizeNanosTON, err = s.ton.GetBalance(ctx, addr) - end := time.Now() if err != nil { // TODO: maybe on this error, just return balance = 0 (?) return nil, fmt.Errorf("%s: failed to get wallet balance: %w", op, err) } - - slog.Info("fetching balance for wallet took", slog.Any("ms", end.Sub(start).Milliseconds())) } if !authenticated { diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index a3a90cb..2ff8e11 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -121,18 +121,20 @@ func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) return models.Entry{}, fmt.Errorf("%s: failed to parse recepient address: %w", op, err) } - // TODO: unhardcode, move to contest settings - amount := tlb.FromNanoTON(big.NewInt(500000000)) + amount := tlb.FromNanoTON(big.NewInt(int64(contest.EntryPriceTonNanos))) - exists := s.ton.LookupTx(ctx, from, to, amount) + tx, exists := s.ton.LookupTx(ctx, from, to, amount) + if !exists { + return entry, nil + } - if exists { - err = s.repo.Entry.MarkAsPaid(ctx, entry.ID) - if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) - } - entry.IsPaid = true + err = s.repo.Entry.MarkAsPaid(ctx, entry.ID, tx) + if err != nil { + return models.Entry{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) } + entry.IsPaid = true + entry.TxHash = tx + return entry, nil } diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index f80ae12..071bb2d 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -28,20 +28,21 @@ type Role struct { } type Contest struct { - ID int `db:"id"` - CreatorID int `db:"creator_id"` - CreatorUsername string `db:"creator_username"` - Title string `db:"title"` - Description string `db:"description"` - AwardType string `db:"award_type"` - StartTime time.Time `db:"start_time"` - EndTime time.Time `db:"end_time"` - DurationMins int `db:"duration_mins"` - MaxEntries int `db:"max_entries"` - AllowLateJoin bool `db:"allow_late_join"` - ParticipantsCount int `db:"participants"` - WalletID *int `db:"wallet_id"` - CreatedAt time.Time `db:"created_at"` + ID int `db:"id"` + CreatorID int `db:"creator_id"` + CreatorUsername string `db:"creator_username"` + Title string `db:"title"` + Description string `db:"description"` + AwardType string `db:"award_type"` + EntryPriceTonNanos uint64 `db:"entry_price_ton_nanos"` + StartTime time.Time `db:"start_time"` + EndTime time.Time `db:"end_time"` + DurationMins int `db:"duration_mins"` + MaxEntries int `db:"max_entries"` + AllowLateJoin bool `db:"allow_late_join"` + ParticipantsCount int `db:"participants"` + WalletID *int `db:"wallet_id"` + CreatedAt time.Time `db:"created_at"` } type ContestFilters struct { @@ -95,6 +96,7 @@ type Entry struct { ContestID int `db:"contest_id"` UserID int `db:"user_id"` IsPaid bool `db:"is_paid"` + TxHash string `db:"tx_hash"` CreatedAt time.Time `db:"created_at"` } diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index f291152..02a9198 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -69,12 +69,12 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, var contest models.Contest query := ` SELECT - id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, + id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, participants_count, created_at FROM contest_details WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( - &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.StartTime, + &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.EntryPriceTonNanos, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, &contest.WalletID, &contest.ParticipantsCount, &contest.CreatedAt) return contest, err @@ -146,7 +146,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT - id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, max_entries, + id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, participants_count, created_at FROM contest_details %s @@ -188,7 +188,7 @@ LIMIT $1 OFFSET $2 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { @@ -216,7 +216,7 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch := &pgx.Batch{} batch.Queue(` SELECT - id, creator_id, creator_username, title, description, award_type, start_time, end_time, duration_mins, max_entries, + id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, participants_count, created_at FROM contest_details WHERE creator_id = $1 @@ -238,7 +238,7 @@ LIMIT $2 OFFSET $3 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { diff --git a/internal/storage/repository/postgres/entry/entry.go b/internal/storage/repository/postgres/entry/entry.go index 452ba33..0b76151 100644 --- a/internal/storage/repository/postgres/entry/entry.go +++ b/internal/storage/repository/postgres/entry/entry.go @@ -27,7 +27,7 @@ func (p *Postgres) Create(ctx context.Context, contestID int, userID int) (int, } func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) { - query := `SELECT id, contest_id, user_id, is_paid, created_at FROM entries + query := `SELECT id, contest_id, user_id, is_paid, tx_hash, created_at FROM entries WHERE contest_id = $1 AND user_id = $2` var entry models.Entry @@ -36,6 +36,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E &entry.ContestID, &entry.UserID, &entry.IsPaid, + &entry.TxHash, &entry.CreatedAt, ) if err != nil { @@ -44,8 +45,8 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E return entry, nil } -func (p *Postgres) MarkAsPaid(ctx context.Context, entryID int) error { - query := `UPDATE entries SET is_paid = true WHERE id = $1` - _, err := p.conn.Exec(ctx, query, entryID) +func (p *Postgres) MarkAsPaid(ctx context.Context, entryID int, txHash string) error { + query := `UPDATE entries SET is_paid = true, tx_hash = $1 WHERE id = $2` + _, err := p.conn.Exec(ctx, query, txHash, entryID) return err } diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 504695e..8cde63d 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -98,7 +98,7 @@ type Problem interface { type Entry interface { Create(ctx context.Context, contestID int, userID int) (int, error) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) - MarkAsPaid(ctx context.Context, entryID int) error + MarkAsPaid(ctx context.Context, entryID int, txHash string) error } type Submission interface { diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_create_tables.up.sql index 799f047..eca7c8c 100644 --- a/migrations/000001_create_tables.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -39,6 +39,7 @@ CREATE TABLE contests ( title VARCHAR(64) NOT NULL, description VARCHAR(300) DEFAULT '' NOT NULL, award_type award_type NOT NULL, + entry_price_ton_nanos BIGINT DEFAULT 0 NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), @@ -85,6 +86,7 @@ CREATE TABLE entries ( contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, is_paid BOOLEAN DEFAULT false NOT NULL, + tx_hash VARCHAR(64) NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT now() NOT NULL, UNIQUE (contest_id, user_id) ); diff --git a/migrations/000002_create_views.up.sql b/migrations/000002_create_views.up.sql index bdfb4a1..dcb9d79 100644 --- a/migrations/000002_create_views.up.sql +++ b/migrations/000002_create_views.up.sql @@ -30,6 +30,7 @@ SELECT c.title, c.description, c.award_type, + c.entry_price_ton_nanos, c.start_time, c.end_time, c.duration_mins, diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 5d305e2..7a66db1 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -114,43 +114,43 @@ func FromNano(nano uint64) string { return tlb.FromNanoTONU(nano).String() } -func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *address.Address, amount tlb.Coins) bool { +func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *address.Address, amount tlb.Coins) (string, bool) { block, err := c.api.CurrentMasterchainInfo(ctx) if err != nil { - return false + return "", false } account, err := c.api.GetAccount(ctx, block, to) if err != nil { - return false + return "", false } if !account.IsActive { - return false + return "", false } - txList, err := c.api.ListTransactions(ctx, to, 100, account.LastTxLT, account.LastTxHash) + txs, err := c.api.ListTransactions(ctx, to, 100, account.LastTxLT, account.LastTxHash) if err != nil { - return false + return "", false } - for _, tx := range txList { + for _, tx := range txs { if tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal { continue } - inMsg := tx.IO.In.AsInternal() - if inMsg == nil { + inmsg := tx.IO.In.AsInternal() + if inmsg == nil { continue } - if inMsg.SrcAddr.Equals(from) { + if inmsg.SrcAddr.Equals(from) { // checks if in tx transferred at least `amount` - if inMsg.Amount.Nano().Cmp(amount.Nano()) >= 0 { - return true + if inmsg.Amount.Nano().Cmp(amount.Nano()) >= 0 { + return fmt.Sprintf("%x", tx.Hash), true } } } - return false + return "", false } From b3b768dd5a29a2f248703960c6f420a8ddb2c955 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 18:51:06 +0400 Subject: [PATCH 24/56] chore: add entry price to create contest params --- internal/app/handler/contest.go | 21 ++++++------- internal/app/handler/dto/request/request.go | 19 ++++++------ internal/app/service/contest.go | 30 +++++++++++-------- internal/app/service/errors.go | 3 ++ .../repository/postgres/contest/contest.go | 17 ++++------- internal/storage/repository/repository.go | 2 +- 6 files changed, 48 insertions(+), 44 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 478b146..3a6595b 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -23,16 +23,17 @@ func (h *Handler) CreateContest(c echo.Context) error { } id, err := h.service.Contest.CreateContest(ctx, service.CreateContestParams{ - UserID: claims.UserID, - Title: body.Title, - Description: body.Description, - AwardType: body.AwardType, - StartTime: body.StartTime, - EndTime: body.EndTime, - DurationMins: body.DurationMins, - MaxEntries: body.MaxEntries, - AllowLateJoin: body.AllowLateJoin, - ProblemIDs: body.ProblemsIDs, + UserID: claims.UserID, + Title: body.Title, + Description: body.Description, + AwardType: body.AwardType, + EntryPriceTonNanos: body.EntryPriceTonNanos, + StartTime: body.StartTime, + EndTime: body.EndTime, + DurationMins: body.DurationMins, + MaxEntries: body.MaxEntries, + AllowLateJoin: body.AllowLateJoin, + ProblemIDs: body.ProblemsIDs, }) if err != nil { switch { diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index 848f1eb..b8e5309 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -17,15 +17,16 @@ type CreateSession struct { } type CreateContest struct { - Title string `json:"title" required:"true"` - Description string `json:"description"` - AwardType string `json:"award_type"` - ProblemsIDs []int `json:"problems_ids" required:"true"` - StartTime time.Time `json:"start_time" required:"true"` - EndTime time.Time `json:"end_time" required:"true"` - DurationMins int `json:"duration_mins" requried:"true"` - MaxEntries int `json:"max_entries"` - AllowLateJoin bool `json:"allow_late_join"` + Title string `json:"title" required:"true"` + Description string `json:"description"` + AwardType string `json:"award_type"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos"` + ProblemsIDs []int `json:"problems_ids" required:"true"` + StartTime time.Time `json:"start_time" required:"true"` + EndTime time.Time `json:"end_time" required:"true"` + DurationMins int `json:"duration_mins" requried:"true"` + MaxEntries int `json:"max_entries"` + AllowLateJoin bool `json:"allow_late_join"` } type CreateProblem struct { diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 16e72b4..c551b0d 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -29,16 +29,17 @@ func NewContestService(repo *repository.Repository, tc *ton.Client) *ContestServ // CreateContestParams contains parameters for creating a contest type CreateContestParams struct { - UserID int - Title string - Description string - AwardType string - StartTime time.Time - EndTime time.Time - DurationMins int - MaxEntries int - AllowLateJoin bool - ProblemIDs []int + UserID int + Title string + Description string + AwardType string + EntryPriceTonNanos uint64 + StartTime time.Time + EndTime time.Time + DurationMins int + MaxEntries int + AllowLateJoin bool + ProblemIDs []int } func (s *ContestService) CreateContest(ctx context.Context, params CreateContestParams) (int, error) { @@ -79,7 +80,8 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest // NOTE: if award type is not `paid_entry` or `sponsored` - use `no_prize` by default var contestID int - if params.AwardType == award.Pool || params.AwardType == award.Sponsored { + switch params.AwardType { + case award.Sponsored, award.Pool: w, err := s.ton.CreateWallet() if err != nil { return 0, err @@ -102,6 +104,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest params.Title, params.Description, params.AwardType, + params.EntryPriceTonNanos, params.StartTime, params.EndTime, params.DurationMins, @@ -119,13 +122,14 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest if err != nil { return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) } - } else { + case award.No: contestID, err = s.repo.Contest.Create( ctx, params.UserID, params.Title, params.Description, award.No, + 0, params.StartTime, params.EndTime, params.DurationMins, @@ -137,6 +141,8 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest if err != nil { return 0, fmt.Errorf("%s: failed to create contest: %w", op, err) } + default: + return 0, ErrUnknownAwardType } return contestID, nil diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index e59529b..d5b4596 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -10,6 +10,9 @@ var ( ErrTokenGeneration = errors.New("failed to generate token") ErrInvalidToken = errors.New("invalid or expired token") + // contest + ErrUnknownAwardType = errors.New("unknown award type") + // entry ErrContestFinished = errors.New("contest not found") ErrContestNotFound = errors.New("contest not found") diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 02a9198..5e1cc17 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -21,21 +21,14 @@ func New(txr postgres.Transactor) *Postgres { return &Postgres{conn: txr} } -func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) { +func (p *Postgres) Create(ctx context.Context, creatorID int, title, desc, awardType string, entryPriceTonNanos uint64, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) { var contestID int var err error - if walletID != nil { - err = p.conn.QueryRow(ctx, ` -INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id - `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin, walletID).Scan(&contestID) - } else { - err = p.conn.QueryRow(ctx, ` -INSERT INTO contests (creator_id, title, description, award_type, start_time, end_time, duration_mins, max_entries, allow_late_join) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id - `, creatorID, title, desc, awardType, startTime, endTime, durationMins, maxEntries, allowLateJoin).Scan(&contestID) - } + err = p.conn.QueryRow(ctx, ` +INSERT INTO contests (creator_id, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id +`, creatorID, title, desc, awardType, entryPriceTonNanos, startTime, endTime, durationMins, maxEntries, allowLateJoin, walletID).Scan(&contestID) if err != nil { return 0, fmt.Errorf("insert contest failed: %w", err) diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 8cde63d..b8ec2b4 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -69,7 +69,7 @@ type User interface { } type Contest interface { - Create(ctx context.Context, creatorID int, title, desc, awardType string, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) + Create(ctx context.Context, creatorID int, title, desc, awardType string, entryPriceTonNanos uint64, startTime, endTime time.Time, durationMins, maxEntries int, allowLateJoin bool, problems []models.ProblemCharcode, walletID *int) (int, error) GetByID(ctx context.Context, contestID int) (models.Contest, error) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) ListAll(ctx context.Context, limit int, offset int, filters models.ContestFilters) (contests []models.Contest, total int, err error) From dc166138cc03257ca76aa5e4b4f100b49a557c63 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 19:19:22 +0400 Subject: [PATCH 25/56] chore: add `is_admitted` field to entry response --- internal/app/handler/dto/response/response.go | 14 +++-- internal/app/handler/entry.go | 18 +++--- internal/app/service/entry.go | 58 ++++++++++++++----- 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 3d64ab5..d3b1df8 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -166,10 +166,12 @@ type TC struct { } type Entry struct { - ID int `json:"id"` - ContestID int `json:"contest_id"` - UserID int `json:"user_id"` - IsPaid bool `json:"is_paid"` - TxHash string `json:"tx_hash,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID int `json:"id"` + ContestID int `json:"contest_id"` + UserID int `json:"user_id"` + IsPaid bool `json:"is_paid"` + TxHash string `json:"tx_hash,omitempty"` + IsAdmitted bool `json:"is_admitted"` + Message string `json:"message,omitempty"` + CreatedAt time.Time `json:"created_at"` } diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index a35286e..a4c5dc8 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -48,7 +48,7 @@ func (h *Handler) GetEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - entry, err := h.service.Entry.GetEntry(ctx, contestID, claims.UserID) + details, err := h.service.Entry.GetEntry(ctx, contestID, claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrEntryNotFound): @@ -60,12 +60,16 @@ func (h *Handler) GetEntry(c echo.Context) error { } } + entry := details.Entry + return c.JSON(http.StatusOK, response.Entry{ - ID: entry.ID, - ContestID: entry.ContestID, - UserID: entry.UserID, - IsPaid: entry.IsPaid, - TxHash: entry.TxHash, - CreatedAt: entry.CreatedAt, + ID: entry.ID, + ContestID: entry.ContestID, + UserID: entry.UserID, + IsPaid: entry.IsPaid, + TxHash: entry.TxHash, + IsAdmitted: details.IsAdmitted, + Message: details.Message, + CreatedAt: entry.CreatedAt, }) } diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index 2ff8e11..3a4b6ed 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -69,72 +69,100 @@ func (s *EntryService) CreateEntry(ctx context.Context, contestID int, userID in return nil } -func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) (models.Entry, error) { +type EntryDetails struct { + Entry models.Entry + IsAdmitted bool + Message string +} + +func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) (EntryDetails, error) { op := "service.EntryService.GetEntry" entry, err := s.repo.Entry.Get(ctx, contestID, userID) if errors.Is(err, pgx.ErrNoRows) { - return models.Entry{}, ErrEntryNotFound + return EntryDetails{}, ErrEntryNotFound } if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to get entry: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to get entry: %w", op, err) } if entry.IsPaid { - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: true, + }, nil } contest, err := s.repo.Contest.GetByID(ctx, contestID) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to get contest: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to get contest: %w", op, err) } if contest.AwardType != award.Pool { - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: true, + }, nil } user, err := s.repo.User.GetByID(ctx, userID) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to get user: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to get user: %w", op, err) } if user.Address == nil || *user.Address == "" { - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "connect wallet to your account, and pay entry price to contest's wallet", + }, nil } + // TODO: maybe this is a 5xx if contest.WalletID == nil { - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "contest has ho wallet associated", + }, nil } wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) } from, err := address.ParseAddr(*user.Address) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to parse sender address: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to parse sender address: %w", op, err) } to, err := address.ParseAddr(wallet.Address) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to parse recepient address: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to parse recepient address: %w", op, err) } amount := tlb.FromNanoTON(big.NewInt(int64(contest.EntryPriceTonNanos))) tx, exists := s.ton.LookupTx(ctx, from, to, amount) if !exists { - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "tx not found", + }, nil } err = s.repo.Entry.MarkAsPaid(ctx, entry.ID, tx) if err != nil { - return models.Entry{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) } entry.IsPaid = true entry.TxHash = tx - return entry, nil + return EntryDetails{ + Entry: entry, + IsAdmitted: true, + }, nil } From 47d9b142e4f1a7faac2dd95e06e03cce10be2cd3 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 19:46:21 +0400 Subject: [PATCH 26/56] chore: add address to contest detailed response --- internal/app/handler/contest.go | 1 + internal/app/handler/dto/response/response.go | 1 + internal/app/service/contest.go | 3 +++ 3 files changed, 5 insertions(+) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 3a6595b..582ba02 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -81,6 +81,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { Description: contest.Description, AwardType: contest.AwardType, EntryPriceTonNanos: contest.EntryPriceTonNanos, + Address: details.WalletAddress, Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index d3b1df8..11588ad 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -48,6 +48,7 @@ type ContestDetailed struct { Description string `json:"description"` AwardType string `json:"award_type"` EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos,omitempty"` + Address string `json:"address,omitempty"` Creator User `json:"creator"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index c551b0d..3c7794b 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -154,6 +154,7 @@ type ContestDetails struct { IsParticipant bool SubmissionDeadline *time.Time ProblemStatuses map[int]string + WalletAddress string PrizeNanosTON uint64 } @@ -191,6 +192,8 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to get wallet: %w", op, err) } + details.WalletAddress = wallet.Address + addr, err := address.ParseAddr(wallet.Address) if err != nil { return nil, fmt.Errorf("%s: failed to parse wallet address: %w", op, err) From efe166db7aafc9d921599ee0d348519660a4d6ff Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 5 Nov 2025 20:39:15 +0400 Subject: [PATCH 27/56] chore: cleanup omit empties --- internal/app/handler/dto/response/response.go | 6 +++--- internal/app/service/entry.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 11588ad..d2a7c6b 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -47,7 +47,7 @@ type ContestDetailed struct { Title string `json:"title"` Description string `json:"description"` AwardType string `json:"award_type"` - EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos,omitempty"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos"` Address string `json:"address,omitempty"` Creator User `json:"creator"` StartTime time.Time `json:"start_time"` @@ -72,7 +72,7 @@ type ContestListItem struct { Creator User `json:"creator"` Title string `json:"title"` AwardType string `json:"award_type"` - EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos,omitempty"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` DurationMins int `json:"duration_mins"` @@ -97,7 +97,7 @@ type TestingReport struct { PassedTestsCount int `json:"passed_tests_count"` TotalTestsCount int `json:"total_tests_count"` FailedTest *Test `json:"failed_test,omitempty"` - Stderr string `json:"stderr"` + Stderr string `json:"stderr,omitemtpy"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index 3a4b6ed..f1e7e76 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -118,7 +118,7 @@ func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) }, nil } - // TODO: maybe this is a 5xx + // TODO: maybe this is a 5xx error if contest.WalletID == nil { return EntryDetails{ Entry: entry, From bf08f489c7a323a3c0a203d557b73b590e69f11a Mon Sep 17 00:00:00 2001 From: jus1d Date: Thu, 6 Nov 2025 19:11:29 +0400 Subject: [PATCH 28/56] chore: forbid to view contest problem if entry is not paid --- internal/app/handler/problem.go | 2 ++ internal/app/service/errors.go | 1 + internal/app/service/problem.go | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 696fec0..682bda4 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -117,6 +117,8 @@ func (h *Handler) GetContestProblem(c echo.Context) error { details, err := h.service.Problem.GetContestProblem(ctx, contestID, claims.UserID, charcode) if err != nil { switch { + case errors.Is(err, service.ErrEntryNotPaid): + return Error(http.StatusForbidden, "entry not paid") case errors.Is(err, service.ErrInvalidCharcode): return Error(http.StatusBadRequest, "problem charcode couldn't be longer than 2 characters") case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index d5b4596..4881dbf 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -20,6 +20,7 @@ var ( ErrApplicationTimeOver = errors.New("application time is over") ErrEntryAlreadyExists = errors.New("user already has entry for this contest") ErrEntryNotFound = errors.New("entry not found") + ErrEntryNotPaid = errors.New("entry not paid") // problem ErrUserBanned = errors.New("you are banned from creating problems") diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index 54dff73..402c4f4 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/models/award" "github.com/voidcontests/api/internal/storage/repository" ) @@ -175,6 +176,10 @@ func (s *ProblemService) GetContestProblem(ctx context.Context, contestID int, u return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) } + if contest.AwardType == award.Pool && !entry.IsPaid { + return nil, ErrEntryNotPaid + } + problem, err := s.repo.Problem.Get(ctx, contestID, charcode) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrProblemNotFound From cd96f8500611487cade0dc8d3df442712da3dc11 Mon Sep 17 00:00:00 2001 From: jus1d Date: Fri, 7 Nov 2025 20:13:54 +0400 Subject: [PATCH 29/56] chore: add awards distributions --- internal/app/distributor/distributor.go | 83 +++++++++++++++++++ internal/app/handler/contest.go | 3 + internal/app/handler/dto/response/response.go | 2 + internal/pkg/app/app.go | 12 +++ internal/storage/models/models.go | 1 + .../repository/postgres/contest/contest.go | 79 +++++++++++++++--- .../repository/postgres/problem/problem.go | 10 +-- .../postgres/submission/submission.go | 6 +- internal/storage/repository/repository.go | 3 + migrations/000001_create_tables.up.sql | 1 + migrations/000002_create_views.down.sql | 10 +-- migrations/000002_create_views.up.sql | 21 +++-- pkg/scheduler/scheduler.go | 61 ++++++++++++++ 13 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 internal/app/distributor/distributor.go create mode 100644 pkg/scheduler/scheduler.go diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go new file mode 100644 index 0000000..67a6ba7 --- /dev/null +++ b/internal/app/distributor/distributor.go @@ -0,0 +1,83 @@ +package distributor + +import ( + "context" + "fmt" + "log/slog" + "math/big" + + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" +) + +func New(r *repository.Repository, tc *ton.Client) func(ctx context.Context) error { + return func(ctx context.Context) error { + contests, err := r.Contest.GetWithUndistributedAwards(ctx) + if err != nil { + return err + } + + for _, c := range contests { + err := distributeAwardForContest(ctx, r, tc, c) + if err != nil { + return err + } + } + return nil + } +} + +func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc *ton.Client, c models.Contest) error { + w, err := r.Contest.GetWallet(ctx, *c.WalletID) + if err != nil { + return err + } + + wallet, err := tc.WalletWithSeed(w.Mnemonic) + if err != nil { + return err + } + + winnerID, err := r.Contest.GetWinnerID(ctx, c.ID) + if err != nil { + return err + } + + winner, err := r.User.GetByID(ctx, winnerID) + if err != nil { + return err + } + + if winner.Address == nil { + return fmt.Errorf("user has no wallet") + } + + recepient, err := address.ParseAddr(*winner.Address) + if err != nil { + return err + } + + nanos, err := tc.GetBalance(ctx, wallet.Address) + if err != nil { + return err + } + + factor := 1 - 0.02 + amount := tlb.FromNanoTON(big.NewInt(int64(float64(nanos) * factor))) + tx, err := wallet.TransferTo(ctx, recepient, amount, fmt.Sprintf("contests.fckn.engineer: Prize for winning contest #%d", c.ID)) + if err != nil { + return err + } + + slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) + + err = r.Contest.SetAwardDistributed(ctx, c.ID) + if err != nil { + return err + } + + return nil +} diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 582ba02..5af3fe7 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -93,6 +93,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, AllowLateJoin: contest.AllowLateJoin, + AwardDistributed: contest.AwardDistributed, IsParticipant: details.IsParticipant, SubmissionDeadline: details.SubmissionDeadline, Prizes: response.Prizes{ @@ -159,6 +160,7 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, Participants: contest.ParticipantsCount, + AwardDistributed: contest.AwardDistributed, CreatedAt: contest.CreatedAt, } items = append(items, item) @@ -223,6 +225,7 @@ func (h *Handler) GetContests(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, Participants: contest.ParticipantsCount, + AwardDistributed: contest.AwardDistributed, CreatedAt: contest.CreatedAt, } items = append(items, item) diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index d2a7c6b..21c766f 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -56,6 +56,7 @@ type ContestDetailed struct { MaxEntries int `json:"max_entries,omitempty"` Participants int `json:"participants"` AllowLateJoin bool `json:"allow_late_join"` + AwardDistributed bool `json:"award_distributed"` IsParticipant bool `json:"is_participant,omitempty"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` Problems []ContestProblemListItem `json:"problems"` @@ -78,6 +79,7 @@ type ContestListItem struct { DurationMins int `json:"duration_mins"` MaxEntries int `json:"max_entries,omitempty"` Participants int `json:"participants"` + AwardDistributed bool `json:"award_distributed"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index ba9e5f1..395561a 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -9,8 +9,10 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/redis/go-redis/v9" + "github.com/voidcontests/api/internal/app/distributor" "github.com/voidcontests/api/internal/app/router" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/lib/logger/prettyslog" @@ -19,6 +21,7 @@ import ( "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/internal/storage/repository/postgres" "github.com/voidcontests/api/internal/version" + "github.com/voidcontests/api/pkg/scheduler" "github.com/voidcontests/api/pkg/ton" ) @@ -105,6 +108,15 @@ func (a *App) Run() { slog.Info("api: started", slog.String("address", server.Addr)) + interval := 1 * time.Minute + task := distributor.New(repo, tc) + scheduler := scheduler.New(interval, task) + + go func() { + scheduler.Start(ctx) + defer scheduler.Stop() + }() + quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) <-quit diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 071bb2d..f18ad94 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -42,6 +42,7 @@ type Contest struct { AllowLateJoin bool `db:"allow_late_join"` ParticipantsCount int `db:"participants"` WalletID *int `db:"wallet_id"` + AwardDistributed bool `db:"award_distributed"` CreatedAt time.Time `db:"created_at"` } diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 5e1cc17..087f413 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -63,13 +63,13 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, query := ` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, - max_entries, allow_late_join, wallet_id, participants_count, created_at -FROM contest_details + max_entries, allow_late_join, wallet_id, award_distributed, participants_count, created_at +FROM contests_view WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.EntryPriceTonNanos, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, - &contest.WalletID, &contest.ParticipantsCount, &contest.CreatedAt) + &contest.WalletID, &contest.AwardDistributed, &contest.ParticipantsCount, &contest.CreatedAt) return contest, err } @@ -89,7 +89,7 @@ func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.P SELECT problem_id, charcode, writer_id, writer_username, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at -FROM contest_problemsets +FROM contest_problems_view WHERE contest_id = $1 ORDER BY charcode ASC` rows, err := p.conn.Query(ctx, query, contestID) @@ -140,8 +140,8 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, participants_count, created_at -FROM contest_details + allow_late_join, wallet_id, award_distributed, participants_count, created_at +FROM contests_view %s ORDER BY id ASC LIMIT $1 OFFSET $2 @@ -161,7 +161,7 @@ LIMIT $1 OFFSET $2 countWhereClauses = append(countWhereClauses, fmt.Sprintf("LOWER(title) LIKE LOWER($%d)", countParamIndex)) } countWhereClause := strings.Join(countWhereClauses, " AND ") - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM contest_details WHERE %s", countWhereClause) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM contests_view WHERE %s", countWhereClause) if len(countArgs) > 0 { batch.Queue(countQuery, countArgs...) @@ -183,7 +183,7 @@ LIMIT $1 OFFSET $2 if err := rows.Scan( &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() @@ -210,14 +210,14 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch.Queue(` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, participants_count, created_at -FROM contest_details + allow_late_join, wallet_id, award_distributed, participants_count, created_at +FROM contests_view WHERE creator_id = $1 ORDER BY id ASC LIMIT $2 OFFSET $3 `, creatorID, limit, offset) - batch.Queue(`SELECT COUNT(*) FROM contest_details WHERE creator_id = $1`, creatorID) + batch.Queue(`SELECT COUNT(*) FROM contests_view WHERE creator_id = $1`, creatorID) br := p.conn.SendBatch(ctx, batch) @@ -233,7 +233,7 @@ LIMIT $2 OFFSET $3 if err := rows.Scan( &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.ParticipantsCount, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() @@ -267,10 +267,19 @@ func (p *Postgres) IsTitleOccupied(ctx context.Context, title string) (bool, err return count > 0, err } +func (p *Postgres) GetWinnerID(ctx context.Context, contestID int) (int, error) { + query := ` SELECT user_id FROM scores + WHERE contest_id = $1 ORDER BY points DESC LIMIT 1` + + var userID int + err := p.conn.QueryRow(ctx, query, contestID).Scan(&userID) + return userID, err +} + func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) { query := ` SELECT user_id, username, points, COUNT(*) OVER() AS total - FROM leaderboard + FROM scores WHERE contest_id = $1 ORDER BY points DESC LIMIT $2 OFFSET $3 @@ -297,3 +306,47 @@ func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset return leaderboard, total, nil } + +func (p *Postgres) SetAwardDistributed(ctx context.Context, contestID int) error { + query := `UPDATE contests SET award_distributed = true WHERE id = $1` + _, err := p.conn.Exec(ctx, query, contestID) + if err != nil { + return fmt.Errorf("set award_distributed failed: %w", err) + } + return nil +} + +func (p *Postgres) GetWithUndistributedAwards(ctx context.Context) ([]models.Contest, error) { + query := ` +SELECT + id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, + max_entries, allow_late_join, wallet_id, award_distributed, participants_count, created_at +FROM contests_view +WHERE award_distributed = false AND end_time < now() AND awart_type <> 'no' +ORDER BY end_time ASC` + + rows, err := p.conn.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("query contests with undistributed awards failed: %w", err) + } + defer rows.Close() + + contests := make([]models.Contest, 0) + for rows.Next() { + var c models.Contest + if err := rows.Scan( + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, + &c.StartTime, &c.EndTime, &c.DurationMins, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("scan failed: %w", err) + } + contests = append(contests, c) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration failed: %w", err) + } + + return contests, nil +} diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index 07f18a5..07c8a27 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -65,7 +65,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (mod SELECT problem_id, charcode, writer_id, writer_username, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at -FROM contest_problemsets +FROM contest_problems_view WHERE contest_id = $1 AND charcode = $2` row := p.conn.QueryRow(ctx, query, contestID, charcode) @@ -83,7 +83,7 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, query := `SELECT id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at - FROM problem_details + FROM problems_view WHERE id = $1` row := p.conn.QueryRow(ctx, query, problemID) @@ -143,7 +143,7 @@ func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { SELECT id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at -FROM problem_details` +FROM problems_view` rows, err := p.conn.Query(ctx, query) if err != nil { @@ -173,13 +173,13 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, off SELECT id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at -FROM problem_details +FROM problems_view WHERE writer_id = $1 ORDER BY id ASC LIMIT $2 OFFSET $3 `, writerID, limit, offset) - batch.Queue(`SELECT COUNT(*) FROM problem_details WHERE writer_id = $1`, writerID) + batch.Queue(`SELECT COUNT(*) FROM problems_view WHERE writer_id = $1`, writerID) br := p.conn.SendBatch(ctx, batch) diff --git a/internal/storage/repository/postgres/submission/submission.go b/internal/storage/repository/postgres/submission/submission.go index 9e400a7..51ac9ee 100644 --- a/internal/storage/repository/postgres/submission/submission.go +++ b/internal/storage/repository/postgres/submission/submission.go @@ -35,7 +35,7 @@ func (p *Postgres) Create(ctx context.Context, entryID int, problemID int, code } selectQuery := `SELECT id, entry_id, contest_id, problem_id, user_id, username, status, verdict, code, language, created_at - FROM submission_details WHERE id = $1` + FROM submissions_view WHERE id = $1` var submission models.Submission err = p.conn.QueryRow(ctx, selectQuery, submissionID).Scan( @@ -118,7 +118,7 @@ func (p *Postgres) GetProblemStatuses(ctx context.Context, entryID int) (map[int func (p *Postgres) GetByID(ctx context.Context, submissionID int) (models.Submission, error) { query := `SELECT id, entry_id, contest_id, problem_id, user_id, username, status, verdict, code, language, created_at - FROM submission_details WHERE id = $1` + FROM submissions_view WHERE id = $1` var s models.Submission err := p.conn.QueryRow(ctx, query, submissionID).Scan( @@ -145,7 +145,7 @@ func (p *Postgres) ListByProblem(ctx context.Context, entryID int, charcode stri query := ` SELECT s.id, s.entry_id, s.contest_id, s.problem_id, s.user_id, s.username, s.status, s.verdict, s.code, s.language, s.created_at, COUNT(*) OVER() as total_count - FROM submission_details s + FROM submissions_view s JOIN contest_problems cp ON cp.contest_id = s.contest_id AND cp.problem_id = s.problem_id WHERE s.entry_id = $1 AND cp.charcode = $2 ORDER BY s.created_at DESC diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index b8ec2b4..577f7af 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -78,6 +78,9 @@ type Contest interface { IsTitleOccupied(ctx context.Context, title string) (bool, error) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) GetWallet(ctx context.Context, walletID int) (models.Wallet, error) + SetAwardDistributed(ctx context.Context, contestID int) error + GetWithUndistributedAwards(ctx context.Context) ([]models.Contest, error) + GetWinnerID(ctx context.Context, contestID int) (int, error) } type Wallet interface { diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_create_tables.up.sql index eca7c8c..57d8d93 100644 --- a/migrations/000001_create_tables.up.sql +++ b/migrations/000001_create_tables.up.sql @@ -40,6 +40,7 @@ CREATE TABLE contests ( description VARCHAR(300) DEFAULT '' NOT NULL, award_type award_type NOT NULL, entry_price_ton_nanos BIGINT DEFAULT 0 NOT NULL, + award_distributed BOOLEAN DEFAULT false NOT NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), diff --git a/migrations/000002_create_views.down.sql b/migrations/000002_create_views.down.sql index 406c468..723b730 100644 --- a/migrations/000002_create_views.down.sql +++ b/migrations/000002_create_views.down.sql @@ -1,6 +1,6 @@ -DROP VIEW IF EXISTS submission_details; -DROP VIEW IF EXISTS contest_problemsets; +DROP VIEW IF EXISTS submissions_view; +DROP VIEW IF EXISTS contest_problems_view; DROP VIEW IF EXISTS problem_statuses; -DROP VIEW IF EXISTS problem_details; -DROP VIEW IF EXISTS contest_details; -DROP VIEW IF EXISTS leaderboard; +DROP VIEW IF EXISTS problems_view; +DROP VIEW IF EXISTS contests_view; +DROP VIEW IF EXISTS scores; diff --git a/migrations/000002_create_views.up.sql b/migrations/000002_create_views.up.sql index dcb9d79..d61e6c3 100644 --- a/migrations/000002_create_views.up.sql +++ b/migrations/000002_create_views.up.sql @@ -1,4 +1,4 @@ -CREATE VIEW leaderboard AS +CREATE VIEW scores AS SELECT e.contest_id, u.id AS user_id, @@ -13,16 +13,20 @@ SELECT ), 0) AS points FROM users u JOIN entries e ON u.id = e.user_id +JOIN contests c ON e.contest_id = c.id LEFT JOIN ( - SELECT DISTINCT entry_id, problem_id - FROM submissions - WHERE verdict = 'ok' + SELECT DISTINCT s.entry_id, s.problem_id + FROM submissions s + JOIN entries e2 ON s.entry_id = e2.id + JOIN contests c2 ON e2.contest_id = c2.id + WHERE s.verdict = 'ok' + AND s.created_at <= c2.end_time ) s ON e.id = s.entry_id LEFT JOIN problems p ON s.problem_id = p.id GROUP BY e.contest_id, u.id, u.username; -CREATE VIEW contest_details AS +CREATE VIEW contests_view AS SELECT c.id, c.creator_id, @@ -31,6 +35,7 @@ SELECT c.description, c.award_type, c.entry_price_ton_nanos, + c.award_distributed, c.start_time, c.end_time, c.duration_mins, @@ -45,7 +50,7 @@ LEFT JOIN entries e ON e.contest_id = c.id GROUP BY c.id, u.username; -CREATE VIEW problem_details AS +CREATE VIEW problems_view AS SELECT p.id, p.writer_id, @@ -73,7 +78,7 @@ FROM submissions s GROUP BY s.entry_id, s.problem_id; -CREATE VIEW contest_problemsets AS +CREATE VIEW contest_problems_view AS SELECT p.id AS problem_id, cp.charcode, @@ -92,7 +97,7 @@ JOIN contest_problems cp ON p.id = cp.problem_id JOIN users u ON u.id = p.writer_id; -CREATE VIEW submission_details AS +CREATE VIEW submissions_view AS SELECT s.id, s.entry_id, diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go new file mode 100644 index 0000000..c66a7ff --- /dev/null +++ b/pkg/scheduler/scheduler.go @@ -0,0 +1,61 @@ +package scheduler + +import ( + "context" + "log/slog" + "time" + + "github.com/voidcontests/api/internal/lib/logger/sl" +) + +type Task func(ctx context.Context) error + +type Scheduler struct { + interval time.Duration + task Task + stop chan struct{} + done chan struct{} +} + +func New(interval time.Duration, task Task) *Scheduler { + return &Scheduler{ + interval: interval, + task: task, + stop: make(chan struct{}), + done: make(chan struct{}), + } +} + +func (s *Scheduler) Start(ctx context.Context) { + defer close(s.done) + + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + slog.Info("scheduler: started", slog.Duration("interval", s.interval)) + + if err := s.task(ctx); err != nil { + slog.Error("scheduler: task execution failed", sl.Err(err)) + } + + for { + select { + case <-ticker.C: + if err := s.task(ctx); err != nil { + slog.Error("scheduler: task execution failed", sl.Err(err)) + } + case <-s.stop: + slog.Info("scheduler: stopping...") + return + case <-ctx.Done(): + slog.Info("scheduler: context cancelled, stopping...") + return + } + } +} + +func (s *Scheduler) Stop() { + close(s.stop) + <-s.done + slog.Info("scheduler: stopped") +} From 89299dddf7283d08cf4d12d854552556ebdddfd8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Fri, 7 Nov 2025 20:17:51 +0400 Subject: [PATCH 30/56] chore: rename migration files --- .../{000001_create_tables.down.sql => 000001_tables.down.sql} | 0 migrations/{000001_create_tables.up.sql => 000001_tables.up.sql} | 0 .../{000002_create_views.down.sql => 000002_views.down.sql} | 0 migrations/{000002_create_views.up.sql => 000002_views.up.sql} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename migrations/{000001_create_tables.down.sql => 000001_tables.down.sql} (100%) rename migrations/{000001_create_tables.up.sql => 000001_tables.up.sql} (100%) rename migrations/{000002_create_views.down.sql => 000002_views.down.sql} (100%) rename migrations/{000002_create_views.up.sql => 000002_views.up.sql} (100%) diff --git a/migrations/000001_create_tables.down.sql b/migrations/000001_tables.down.sql similarity index 100% rename from migrations/000001_create_tables.down.sql rename to migrations/000001_tables.down.sql diff --git a/migrations/000001_create_tables.up.sql b/migrations/000001_tables.up.sql similarity index 100% rename from migrations/000001_create_tables.up.sql rename to migrations/000001_tables.up.sql diff --git a/migrations/000002_create_views.down.sql b/migrations/000002_views.down.sql similarity index 100% rename from migrations/000002_create_views.down.sql rename to migrations/000002_views.down.sql diff --git a/migrations/000002_create_views.up.sql b/migrations/000002_views.up.sql similarity index 100% rename from migrations/000002_create_views.up.sql rename to migrations/000002_views.up.sql From 243703af6f6938b8717a627aa4da8aa51ad5d7db Mon Sep 17 00:00:00 2001 From: jus1d Date: Fri, 7 Nov 2025 23:25:12 +0400 Subject: [PATCH 31/56] chore: Introduce payments table --- internal/app/distributor/distributor.go | 7 +- internal/app/handler/contest.go | 6 +- internal/app/handler/dto/response/response.go | 2 +- internal/app/handler/entry.go | 10 ++- internal/app/service/contest.go | 10 +-- internal/app/service/entry.go | 12 ++-- internal/app/service/problem.go | 3 +- internal/storage/models/models.go | 45 +++++++----- .../repository/postgres/contest/contest.go | 26 +++---- .../repository/postgres/entry/entry.go | 11 ++- .../repository/postgres/payment/payment.go | 69 +++++++++++++++++++ internal/storage/repository/repository.go | 15 +++- migrations/000001_tables.down.sql | 1 + migrations/000001_tables.up.sql | 21 ++++-- migrations/000002_views.up.sql | 6 +- 15 files changed, 180 insertions(+), 64 deletions(-) create mode 100644 internal/storage/repository/postgres/payment/payment.go diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index 67a6ba7..aeec02b 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -74,7 +74,12 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) - err = r.Contest.SetAwardDistributed(ctx, c.ID) + paymentID, err := r.Payment.Create(ctx, tx, wallet.Address.String(), recepient.String(), amount.Nano().Uint64(), false) + if err != nil { + return err + } + + err = r.Contest.SetDistributionPaymentID(ctx, c.ID, paymentID) if err != nil { return err } diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 5af3fe7..1e38c83 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -93,7 +93,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, AllowLateJoin: contest.AllowLateJoin, - AwardDistributed: contest.AwardDistributed, + AwardDistributed: contest.DistributionPaymentID != nil, IsParticipant: details.IsParticipant, SubmissionDeadline: details.SubmissionDeadline, Prizes: response.Prizes{ @@ -160,7 +160,7 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, Participants: contest.ParticipantsCount, - AwardDistributed: contest.AwardDistributed, + AwardDistributed: contest.DistributionPaymentID != nil, CreatedAt: contest.CreatedAt, } items = append(items, item) @@ -225,7 +225,7 @@ func (h *Handler) GetContests(c echo.Context) error { DurationMins: contest.DurationMins, MaxEntries: contest.MaxEntries, Participants: contest.ParticipantsCount, - AwardDistributed: contest.AwardDistributed, + AwardDistributed: contest.DistributionPaymentID != nil, CreatedAt: contest.CreatedAt, } items = append(items, item) diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 21c766f..782efc9 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -173,7 +173,7 @@ type Entry struct { ContestID int `json:"contest_id"` UserID int `json:"user_id"` IsPaid bool `json:"is_paid"` - TxHash string `json:"tx_hash,omitempty"` + PaymentID int `json:"payment_id,omitempty"` IsAdmitted bool `json:"is_admitted"` Message string `json:"message,omitempty"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index a4c5dc8..1194cc1 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -62,12 +62,18 @@ func (h *Handler) GetEntry(c echo.Context) error { entry := details.Entry + // TODO: put an entire payment (?) + var pid int + if entry.PaymentID != nil { + pid = *entry.PaymentID + } + return c.JSON(http.StatusOK, response.Entry{ ID: entry.ID, ContestID: entry.ContestID, UserID: entry.UserID, - IsPaid: entry.IsPaid, - TxHash: entry.TxHash, + IsPaid: entry.PaymentID != nil, + PaymentID: pid, IsAdmitted: details.IsAdmitted, Message: details.Message, CreatedAt: entry.CreatedAt, diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 3c7794b..fd4a6b0 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -170,11 +170,11 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user } now := time.Now() - if contest.EndTime.Before(now) { - if !authenticated || userID != contest.CreatorID { - return nil, ErrContestFinished - } - } + // if contest.EndTime.Before(now) { + // if !authenticated || userID != contest.CreatorID { + // return nil, ErrContestFinished + // } + // } problems, err := s.repo.Contest.GetProblemset(ctx, contestID) if err != nil { diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go index f1e7e76..e774e4b 100644 --- a/internal/app/service/entry.go +++ b/internal/app/service/entry.go @@ -86,7 +86,7 @@ func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) return EntryDetails{}, fmt.Errorf("%s: failed to get entry: %w", op, err) } - if entry.IsPaid { + if entry.PaymentID != nil { return EntryDetails{ Entry: entry, IsAdmitted: true, @@ -153,13 +153,17 @@ func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) }, nil } - err = s.repo.Entry.MarkAsPaid(ctx, entry.ID, tx) + paymentID, err := s.repo.Payment.Create(ctx, tx, from.String(), to.String(), amount.Nano().Uint64(), true) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to create payment: %w", op, err) + } + + err = s.repo.Entry.SetPaymentID(ctx, entry.ID, paymentID) if err != nil { return EntryDetails{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) } - entry.IsPaid = true - entry.TxHash = tx + entry.PaymentID = &paymentID return EntryDetails{ Entry: entry, diff --git a/internal/app/service/problem.go b/internal/app/service/problem.go index 402c4f4..af6d4a1 100644 --- a/internal/app/service/problem.go +++ b/internal/app/service/problem.go @@ -23,7 +23,6 @@ func NewProblemService(repo *repository.Repository) *ProblemService { } } -// CreateProblemParams contains parameters for creating a problem type CreateProblemParams struct { UserID int Title string @@ -176,7 +175,7 @@ func (s *ProblemService) GetContestProblem(ctx context.Context, contestID int, u return nil, fmt.Errorf("%s: failed to get entry: %w", op, err) } - if contest.AwardType == award.Pool && !entry.IsPaid { + if contest.AwardType == award.Pool && entry.PaymentID == nil { return nil, ErrEntryNotPaid } diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index f18ad94..f5e2e96 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -28,22 +28,22 @@ type Role struct { } type Contest struct { - ID int `db:"id"` - CreatorID int `db:"creator_id"` - CreatorUsername string `db:"creator_username"` - Title string `db:"title"` - Description string `db:"description"` - AwardType string `db:"award_type"` - EntryPriceTonNanos uint64 `db:"entry_price_ton_nanos"` - StartTime time.Time `db:"start_time"` - EndTime time.Time `db:"end_time"` - DurationMins int `db:"duration_mins"` - MaxEntries int `db:"max_entries"` - AllowLateJoin bool `db:"allow_late_join"` - ParticipantsCount int `db:"participants"` - WalletID *int `db:"wallet_id"` - AwardDistributed bool `db:"award_distributed"` - CreatedAt time.Time `db:"created_at"` + ID int `db:"id"` + CreatorID int `db:"creator_id"` + CreatorUsername string `db:"creator_username"` + Title string `db:"title"` + Description string `db:"description"` + AwardType string `db:"award_type"` + EntryPriceTonNanos uint64 `db:"entry_price_ton_nanos"` + StartTime time.Time `db:"start_time"` + EndTime time.Time `db:"end_time"` + DurationMins int `db:"duration_mins"` + MaxEntries int `db:"max_entries"` + AllowLateJoin bool `db:"allow_late_join"` + ParticipantsCount int `db:"participants"` + WalletID *int `db:"wallet_id"` + DistributionPaymentID *int `db:"distribution_payment_id"` + CreatedAt time.Time `db:"created_at"` } type ContestFilters struct { @@ -63,6 +63,16 @@ type Wallet struct { CreatedAt time.Time `db:"created_at"` } +type Payment struct { + ID int `db:"id"` + TxHash string `db:"tx_hash"` + FromAddress string `db:"from_address"` + ToAddress string `db:"to_address"` + AmountTonNanos uint64 `db:"amount_ton_nanos"` + IsIncoming bool `db:"is_incoming"` + CreatedAt time.Time `db:"created_at"` +} + type Problem struct { ID int `db:"id"` Charcode string `db:"charcode"` @@ -96,8 +106,7 @@ type Entry struct { ID int `db:"id"` ContestID int `db:"contest_id"` UserID int `db:"user_id"` - IsPaid bool `db:"is_paid"` - TxHash string `db:"tx_hash"` + PaymentID *int `db:"payment_id"` CreatedAt time.Time `db:"created_at"` } diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 087f413..cb528fd 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -63,13 +63,13 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, query := ` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, - max_entries, allow_late_join, wallet_id, award_distributed, participants_count, created_at + max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.EntryPriceTonNanos, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, - &contest.WalletID, &contest.AwardDistributed, &contest.ParticipantsCount, &contest.CreatedAt) + &contest.WalletID, &contest.DistributionPaymentID, &contest.ParticipantsCount, &contest.CreatedAt) return contest, err } @@ -140,7 +140,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, award_distributed, participants_count, created_at + allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view %s ORDER BY id ASC @@ -183,7 +183,7 @@ LIMIT $1 OFFSET $2 if err := rows.Scan( &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() @@ -210,7 +210,7 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch.Queue(` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, - allow_late_join, wallet_id, award_distributed, participants_count, created_at + allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view WHERE creator_id = $1 ORDER BY id ASC @@ -233,7 +233,7 @@ LIMIT $2 OFFSET $3 if err := rows.Scan( &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { rows.Close() br.Close() @@ -307,11 +307,11 @@ func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset return leaderboard, total, nil } -func (p *Postgres) SetAwardDistributed(ctx context.Context, contestID int) error { - query := `UPDATE contests SET award_distributed = true WHERE id = $1` - _, err := p.conn.Exec(ctx, query, contestID) +func (p *Postgres) SetDistributionPaymentID(ctx context.Context, contestID int, paymentID int) error { + query := `UPDATE contests SET distribution_payment_id = $1 WHERE id = $2` + _, err := p.conn.Exec(ctx, query, paymentID, contestID) if err != nil { - return fmt.Errorf("set award_distributed failed: %w", err) + return fmt.Errorf("set distribution_payment_id failed: %w", err) } return nil } @@ -320,9 +320,9 @@ func (p *Postgres) GetWithUndistributedAwards(ctx context.Context) ([]models.Con query := ` SELECT id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, - max_entries, allow_late_join, wallet_id, award_distributed, participants_count, created_at + max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view -WHERE award_distributed = false AND end_time < now() AND awart_type <> 'no' +WHERE distribution_payment_id IS NULL AND end_time < now() AND award_type <> 'no' ORDER BY end_time ASC` rows, err := p.conn.Query(ctx, query) @@ -337,7 +337,7 @@ ORDER BY end_time ASC` if err := rows.Scan( &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, - &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.AwardDistributed, &c.ParticipantsCount, &c.CreatedAt, + &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { return nil, fmt.Errorf("scan failed: %w", err) } diff --git a/internal/storage/repository/postgres/entry/entry.go b/internal/storage/repository/postgres/entry/entry.go index 0b76151..fbb0e4c 100644 --- a/internal/storage/repository/postgres/entry/entry.go +++ b/internal/storage/repository/postgres/entry/entry.go @@ -27,7 +27,7 @@ func (p *Postgres) Create(ctx context.Context, contestID int, userID int) (int, } func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) { - query := `SELECT id, contest_id, user_id, is_paid, tx_hash, created_at FROM entries + query := `SELECT id, contest_id, user_id, payment_id, created_at FROM entries WHERE contest_id = $1 AND user_id = $2` var entry models.Entry @@ -35,8 +35,7 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E &entry.ID, &entry.ContestID, &entry.UserID, - &entry.IsPaid, - &entry.TxHash, + &entry.PaymentID, &entry.CreatedAt, ) if err != nil { @@ -45,8 +44,8 @@ func (p *Postgres) Get(ctx context.Context, contestID int, userID int) (models.E return entry, nil } -func (p *Postgres) MarkAsPaid(ctx context.Context, entryID int, txHash string) error { - query := `UPDATE entries SET is_paid = true, tx_hash = $1 WHERE id = $2` - _, err := p.conn.Exec(ctx, query, txHash, entryID) +func (p *Postgres) SetPaymentID(ctx context.Context, entryID int, paymentID int) error { + query := `UPDATE entries SET payment_id = $1 WHERE id = $2` + _, err := p.conn.Exec(ctx, query, paymentID, entryID) return err } diff --git a/internal/storage/repository/postgres/payment/payment.go b/internal/storage/repository/postgres/payment/payment.go new file mode 100644 index 0000000..4a56f3a --- /dev/null +++ b/internal/storage/repository/postgres/payment/payment.go @@ -0,0 +1,69 @@ +package payment + +import ( + "context" + "fmt" + + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository/postgres" +) + +type Postgres struct { + conn postgres.Transactor +} + +func New(conn postgres.Transactor) *Postgres { + return &Postgres{conn: conn} +} + +func (p *Postgres) Create(ctx context.Context, txHash, fromAddress, toAddress string, amountTonNanos uint64, isIncoming bool) (int, error) { + var paymentID int + query := `INSERT INTO payments (tx_hash, from_address, to_address, amount_ton_nanos, is_incoming) + VALUES ($1, $2, $3, $4, $5) RETURNING id` + err := p.conn.QueryRow(ctx, query, txHash, fromAddress, toAddress, amountTonNanos, isIncoming).Scan(&paymentID) + if err != nil { + return 0, fmt.Errorf("insert payment failed: %w", err) + } + + return paymentID, nil +} + +func (p *Postgres) GetByID(ctx context.Context, paymentID int) (models.Payment, error) { + var payment models.Payment + query := `SELECT id, tx_hash, from_address, to_address, amount_ton_nanos, is_incoming, created_at + FROM payments WHERE id = $1` + err := p.conn.QueryRow(ctx, query, paymentID).Scan( + &payment.ID, + &payment.TxHash, + &payment.FromAddress, + &payment.ToAddress, + &payment.AmountTonNanos, + &payment.IsIncoming, + &payment.CreatedAt, + ) + if err != nil { + return models.Payment{}, fmt.Errorf("get payment by id failed: %w", err) + } + + return payment, nil +} + +func (p *Postgres) GetByTxHash(ctx context.Context, txHash string) (models.Payment, error) { + var payment models.Payment + query := `SELECT id, tx_hash, from_address, to_address, amount_ton_nanos, is_incoming, created_at + FROM payments WHERE tx_hash = $1` + err := p.conn.QueryRow(ctx, query, txHash).Scan( + &payment.ID, + &payment.TxHash, + &payment.FromAddress, + &payment.ToAddress, + &payment.AmountTonNanos, + &payment.IsIncoming, + &payment.CreatedAt, + ) + if err != nil { + return models.Payment{}, fmt.Errorf("get payment by tx_hash failed: %w", err) + } + + return payment, nil +} diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 577f7af..96c6bea 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -10,6 +10,7 @@ import ( "github.com/voidcontests/api/internal/storage/repository/postgres" "github.com/voidcontests/api/internal/storage/repository/postgres/contest" "github.com/voidcontests/api/internal/storage/repository/postgres/entry" + "github.com/voidcontests/api/internal/storage/repository/postgres/payment" "github.com/voidcontests/api/internal/storage/repository/postgres/problem" "github.com/voidcontests/api/internal/storage/repository/postgres/submission" "github.com/voidcontests/api/internal/storage/repository/postgres/user" @@ -22,6 +23,7 @@ type Repository struct { Problem Problem Entry Entry Submission Submission + Payment Payment TxManager *postgres.TxManager } @@ -32,6 +34,7 @@ func New(pool *pgxpool.Pool) *Repository { Problem: problem.New(pool), Entry: entry.New(pool), Submission: submission.New(pool), + Payment: payment.New(pool), TxManager: postgres.NewTxManager(pool), } } @@ -44,6 +47,7 @@ type TxRepository struct { Entry Entry Submission Submission Wallet Wallet + Payment Payment } // NewTxRepository creates repository instances that use the provided transaction @@ -55,6 +59,7 @@ func NewTxRepository(tx pgx.Tx) *TxRepository { Entry: entry.New(tx), Submission: submission.New(tx), Problem: problem.New(tx), + Payment: payment.New(tx), } } @@ -78,7 +83,7 @@ type Contest interface { IsTitleOccupied(ctx context.Context, title string) (bool, error) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) GetWallet(ctx context.Context, walletID int) (models.Wallet, error) - SetAwardDistributed(ctx context.Context, contestID int) error + SetDistributionPaymentID(ctx context.Context, contestID int, paymentID int) error GetWithUndistributedAwards(ctx context.Context) ([]models.Contest, error) GetWinnerID(ctx context.Context, contestID int) (int, error) } @@ -101,7 +106,7 @@ type Problem interface { type Entry interface { Create(ctx context.Context, contestID int, userID int) (int, error) Get(ctx context.Context, contestID int, userID int) (models.Entry, error) - MarkAsPaid(ctx context.Context, entryID int, txHash string) error + SetPaymentID(ctx context.Context, entryID int, paymentID int) error } type Submission interface { @@ -112,3 +117,9 @@ type Submission interface { ListByProblem(ctx context.Context, entryID int, charcode string, limit int, offset int) (items []models.Submission, total int, err error) GetTestingReport(ctx context.Context, submissionID int) (models.TestingReport, error) } + +type Payment interface { + Create(ctx context.Context, txHash, fromAddress, toAddress string, amountTonNanos uint64, isIncoming bool) (int, error) + GetByID(ctx context.Context, paymentID int) (models.Payment, error) + GetByTxHash(ctx context.Context, txHash string) (models.Payment, error) +} diff --git a/migrations/000001_tables.down.sql b/migrations/000001_tables.down.sql index 75dd3ba..cb55e19 100644 --- a/migrations/000001_tables.down.sql +++ b/migrations/000001_tables.down.sql @@ -5,6 +5,7 @@ DROP TABLE IF EXISTS contest_problems; DROP TABLE IF EXISTS test_cases; DROP TABLE IF EXISTS problems; DROP TABLE IF EXISTS contests; +DROP TABLE IF EXISTS payments; DROP TABLE IF EXISTS wallets; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS roles; diff --git a/migrations/000001_tables.up.sql b/migrations/000001_tables.up.sql index 57d8d93..e57f601 100644 --- a/migrations/000001_tables.up.sql +++ b/migrations/000001_tables.up.sql @@ -22,15 +22,27 @@ CREATE TABLE users ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE UNIQUE INDEX unique_user_address ON users(address) WHERE address IS NOT NULL; + -- TODO: add unique index on `contests.wallet_id` -- TODO: encrypt the mnemonic before saving CREATE TABLE wallets ( id SERIAL PRIMARY KEY, - address VARCHAR(100) NOT NULL, + address VARCHAR(100) UNIQUE NOT NULL, mnemonic TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE TABLE payments ( + id SERIAL PRIMARY KEY, + tx_hash VARCHAR(64) UNIQUE NOT NULL, + from_address VARCHAR(100) NOT NULL, + to_address VARCHAR(100) NOT NULL, + amount_ton_nanos BIGINT NOT NULL CHECK (amount_ton_nanos >= 0), + is_incoming BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT now() NOT NULL +); + CREATE TYPE award_type AS ENUM ('no', 'pool', 'sponsored'); CREATE TABLE contests ( @@ -40,7 +52,7 @@ CREATE TABLE contests ( description VARCHAR(300) DEFAULT '' NOT NULL, award_type award_type NOT NULL, entry_price_ton_nanos BIGINT DEFAULT 0 NOT NULL, - award_distributed BOOLEAN DEFAULT false NOT NULL, + distribution_payment_id INTEGER REFERENCES payments(id) ON DELETE SET NULL, start_time TIMESTAMP NOT NULL, end_time TIMESTAMP NOT NULL, duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), @@ -50,6 +62,8 @@ CREATE TABLE contests ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE UNIQUE INDEX unique_contest_wallet_id ON contests(wallet_id) WHERE wallet_id IS NOT NULL; + CREATE TYPE difficulty AS ENUM ('easy', 'mid', 'hard'); CREATE TABLE problems ( @@ -86,8 +100,7 @@ CREATE TABLE entries ( id SERIAL PRIMARY KEY, contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - is_paid BOOLEAN DEFAULT false NOT NULL, - tx_hash VARCHAR(64) NOT NULL DEFAULT '', + payment_id INTEGER REFERENCES payments(id) ON DELETE SET NULL, created_at TIMESTAMP DEFAULT now() NOT NULL, UNIQUE (contest_id, user_id) ); diff --git a/migrations/000002_views.up.sql b/migrations/000002_views.up.sql index d61e6c3..37a5ec9 100644 --- a/migrations/000002_views.up.sql +++ b/migrations/000002_views.up.sql @@ -6,8 +6,8 @@ SELECT COALESCE(SUM( CASE WHEN p.difficulty = 'easy' THEN 1 - WHEN p.difficulty = 'mid' THEN 3 - WHEN p.difficulty = 'hard' THEN 5 + WHEN p.difficulty = 'mid' THEN 2 + WHEN p.difficulty = 'hard' THEN 3 ELSE 0 END ), 0) AS points @@ -35,7 +35,7 @@ SELECT c.description, c.award_type, c.entry_price_ton_nanos, - c.award_distributed, + c.distribution_payment_id, c.start_time, c.end_time, c.duration_mins, From 66a60bab5e84e1dd91cdb0d110bbbbb134ba00a1 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 00:00:58 +0400 Subject: [PATCH 32/56] chore: drop indecies --- .gitignore | 2 ++ migrations/000001_tables.down.sql | 3 +++ 2 files changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 737bd40..160b366 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ build # Ignore TODO TODO buffer + +migrations/999999_*.sql diff --git a/migrations/000001_tables.down.sql b/migrations/000001_tables.down.sql index cb55e19..c99b88f 100644 --- a/migrations/000001_tables.down.sql +++ b/migrations/000001_tables.down.sql @@ -1,3 +1,6 @@ +DROP INDEX IF EXISTS unique_user_address; +DROP INDEX IF EXISTS unique_contest_wallet_id; + DROP TABLE IF EXISTS testing_reports; DROP TABLE IF EXISTS submissions; DROP TABLE IF EXISTS entries; From 283c4f8c1087bc5d91200b68a670be1178aa17a8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 02:47:02 +0400 Subject: [PATCH 33/56] chore: Improve migrations, add indecies, make views materialized --- migrations/000001_tables.down.sql | 17 ++++++++++++++-- migrations/000001_tables.up.sql | 34 +++++++++++++++++++++++-------- migrations/000002_views.down.sql | 5 +++-- migrations/000002_views.up.sql | 10 +++++++-- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/migrations/000001_tables.down.sql b/migrations/000001_tables.down.sql index c99b88f..5182742 100644 --- a/migrations/000001_tables.down.sql +++ b/migrations/000001_tables.down.sql @@ -1,5 +1,18 @@ -DROP INDEX IF EXISTS unique_user_address; +DROP INDEX IF EXISTS idx_testing_reports_first_failed_test_id; +DROP INDEX IF EXISTS idx_testing_reports_submission_id; +DROP INDEX IF EXISTS idx_submissions_problem_id; +DROP INDEX IF EXISTS idx_submissions_entry_id; +DROP INDEX IF EXISTS idx_entries_payment_id; +DROP INDEX IF EXISTS idx_entries_user_id; +DROP INDEX IF EXISTS idx_entries_contest_id; +DROP INDEX IF EXISTS idx_test_cases_problem_id; +DROP INDEX IF EXISTS idx_problems_writer_id; DROP INDEX IF EXISTS unique_contest_wallet_id; +DROP INDEX IF EXISTS idx_contests_wallet_id; +DROP INDEX IF EXISTS idx_contests_distribution_payment_id; +DROP INDEX IF EXISTS idx_contests_creator_id; +DROP INDEX IF EXISTS unique_user_address; +DROP INDEX IF EXISTS idx_users_role_id; DROP TABLE IF EXISTS testing_reports; DROP TABLE IF EXISTS submissions; @@ -13,5 +26,5 @@ DROP TABLE IF EXISTS wallets; DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS roles; -DROP TYPE IF EXISTS award_type; DROP TYPE IF EXISTS difficulty; +DROP TYPE IF EXISTS award_type; diff --git a/migrations/000001_tables.up.sql b/migrations/000001_tables.up.sql index e57f601..5289715 100644 --- a/migrations/000001_tables.up.sql +++ b/migrations/000001_tables.up.sql @@ -18,17 +18,17 @@ CREATE TABLE users ( username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, - address VARCHAR(100), + address VARCHAR(48), created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE INDEX idx_users_role_id ON users(role_id); CREATE UNIQUE INDEX unique_user_address ON users(address) WHERE address IS NOT NULL; --- TODO: add unique index on `contests.wallet_id` -- TODO: encrypt the mnemonic before saving CREATE TABLE wallets ( id SERIAL PRIMARY KEY, - address VARCHAR(100) UNIQUE NOT NULL, + address VARCHAR(48) UNIQUE NOT NULL, mnemonic TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); @@ -36,8 +36,8 @@ CREATE TABLE wallets ( CREATE TABLE payments ( id SERIAL PRIMARY KEY, tx_hash VARCHAR(64) UNIQUE NOT NULL, - from_address VARCHAR(100) NOT NULL, - to_address VARCHAR(100) NOT NULL, + from_address VARCHAR(48) NOT NULL, + to_address VARCHAR(48) NOT NULL, amount_ton_nanos BIGINT NOT NULL CHECK (amount_ton_nanos >= 0), is_incoming BOOLEAN NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL @@ -58,17 +58,21 @@ CREATE TABLE contests ( duration_mins INTEGER NOT NULL CHECK (duration_mins >= 0), max_entries INTEGER DEFAULT 0 NOT NULL CHECK (max_entries >= 0), allow_late_join BOOLEAN DEFAULT true NOT NULL, - wallet_id INTEGER REFERENCES wallets(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT now() NOT NULL + wallet_id INTEGER REFERENCES wallets(id) ON DELETE RESTRICT, + created_at TIMESTAMP DEFAULT now() NOT NULL, + CHECK (start_time < end_time) ); +CREATE INDEX idx_contests_creator_id ON contests(creator_id); +CREATE INDEX idx_contests_distribution_payment_id ON contests(distribution_payment_id); +CREATE INDEX idx_contests_wallet_id ON contests(wallet_id); CREATE UNIQUE INDEX unique_contest_wallet_id ON contests(wallet_id) WHERE wallet_id IS NOT NULL; CREATE TYPE difficulty AS ENUM ('easy', 'mid', 'hard'); CREATE TABLE problems ( id SERIAL PRIMARY KEY, - writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + writer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, title VARCHAR(64) NOT NULL, statement TEXT NOT NULL, difficulty difficulty NOT NULL, @@ -78,6 +82,8 @@ CREATE TABLE problems ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE INDEX idx_problems_writer_id ON problems(writer_id); + CREATE TABLE test_cases ( id SERIAL PRIMARY KEY, problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, @@ -88,6 +94,8 @@ CREATE TABLE test_cases ( UNIQUE (problem_id, ordinal) ); +CREATE INDEX idx_test_cases_problem_id ON test_cases(problem_id); + CREATE TABLE contest_problems ( contest_id INTEGER NOT NULL REFERENCES contests(id) ON DELETE CASCADE, problem_id INTEGER NOT NULL REFERENCES problems(id) ON DELETE CASCADE, @@ -105,6 +113,10 @@ CREATE TABLE entries ( UNIQUE (contest_id, user_id) ); +CREATE INDEX idx_entries_contest_id ON entries(contest_id); +CREATE INDEX idx_entries_user_id ON entries(user_id); +CREATE INDEX idx_entries_payment_id ON entries(payment_id); + CREATE TABLE submissions ( id SERIAL PRIMARY KEY, entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, @@ -116,6 +128,9 @@ CREATE TABLE submissions ( created_at TIMESTAMP DEFAULT now() NOT NULL ); +CREATE INDEX idx_submissions_entry_id ON submissions(entry_id); +CREATE INDEX idx_submissions_problem_id ON submissions(problem_id); + CREATE TABLE testing_reports ( id SERIAL PRIMARY KEY, submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE, @@ -126,3 +141,6 @@ CREATE TABLE testing_reports ( stderr TEXT DEFAULT '' NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); + +CREATE INDEX idx_testing_reports_submission_id ON testing_reports(submission_id); +CREATE INDEX idx_testing_reports_first_failed_test_id ON testing_reports(first_failed_test_id); diff --git a/migrations/000002_views.down.sql b/migrations/000002_views.down.sql index 723b730..7b2f28d 100644 --- a/migrations/000002_views.down.sql +++ b/migrations/000002_views.down.sql @@ -2,5 +2,6 @@ DROP VIEW IF EXISTS submissions_view; DROP VIEW IF EXISTS contest_problems_view; DROP VIEW IF EXISTS problem_statuses; DROP VIEW IF EXISTS problems_view; -DROP VIEW IF EXISTS contests_view; -DROP VIEW IF EXISTS scores; + +DROP MATERIALIZED VIEW IF EXISTS contests_view; +DROP MATERIALIZED VIEW IF EXISTS scores; diff --git a/migrations/000002_views.up.sql b/migrations/000002_views.up.sql index 37a5ec9..182f9e4 100644 --- a/migrations/000002_views.up.sql +++ b/migrations/000002_views.up.sql @@ -1,4 +1,4 @@ -CREATE VIEW scores AS +CREATE MATERIALIZED VIEW scores AS SELECT e.contest_id, u.id AS user_id, @@ -25,8 +25,12 @@ LEFT JOIN ( LEFT JOIN problems p ON s.problem_id = p.id GROUP BY e.contest_id, u.id, u.username; +CREATE UNIQUE INDEX idx_scores_contest_user ON scores(contest_id, user_id); +CREATE INDEX idx_scores_contest_id ON scores(contest_id); +CREATE INDEX idx_scores_user_id ON scores(user_id); -CREATE VIEW contests_view AS + +CREATE MATERIALIZED VIEW contests_view AS SELECT c.id, c.creator_id, @@ -49,6 +53,8 @@ JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id GROUP BY c.id, u.username; +CREATE UNIQUE INDEX idx_contests_view_id ON contests_view(id); + CREATE VIEW problems_view AS SELECT From bb6bc919e251717b839be41cebad7d1150711b03 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 02:47:34 +0400 Subject: [PATCH 34/56] chore: Save wallet adresses as testnet, if config setted as testnet --- internal/app/distributor/distributor.go | 4 +- internal/app/handler/account.go | 38 +++++++++++++++++++ internal/app/handler/dto/request/request.go | 5 +++ internal/app/handler/dto/response/response.go | 5 ++- internal/app/router/router.go | 1 + internal/app/service/account.go | 25 ++++++++++++ internal/app/service/contest.go | 2 +- internal/config/config.go | 6 +++ internal/pkg/app/app.go | 2 +- internal/storage/models/models.go | 5 +++ .../storage/repository/postgres/user/user.go | 38 +++++++++++++++++++ internal/storage/repository/repository.go | 4 +- pkg/scheduler/scheduler.go | 2 +- pkg/ton/ton.go | 21 +++++++--- 14 files changed, 143 insertions(+), 15 deletions(-) diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index aeec02b..2981a8e 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -60,7 +60,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc return err } - nanos, err := tc.GetBalance(ctx, wallet.Address) + nanos, err := tc.GetBalance(ctx, wallet.Address()) if err != nil { return err } @@ -74,7 +74,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) - paymentID, err := r.Payment.Create(ctx, tx, wallet.Address.String(), recepient.String(), amount.Nano().Uint64(), false) + paymentID, err := r.Payment.Create(ctx, tx, wallet.Address().String(), recepient.String(), amount.Nano().Uint64(), false) if err != nil { return err } diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 11769d1..2944ef6 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -13,6 +13,7 @@ import ( "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/jwt" "github.com/voidcontests/api/internal/lib/logger/sl" + "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/validate" ) @@ -83,6 +84,43 @@ func (h *Handler) GetAccount(c echo.Context) error { }) } +func (h *Handler) UpdateAccount(c echo.Context) error { + ctx := c.Request().Context() + + claims, _ := ExtractClaims(c) + + var body request.UpdateAccount + if err := validate.Bind(c, &body); err != nil { + return Error(http.StatusBadRequest, "invalid body: missing required fields") + } + + if body.Username == nil && body.Address == nil { + return Error(http.StatusBadRequest, "at least one field must be provided") + } + + params := models.UpdateUserParams{ + Username: body.Username, + Address: body.Address, + } + + user, err := h.service.Account.UpdateAccount(ctx, claims.UserID, params) + if err != nil { + if errors.Is(err, service.ErrUserAlreadyExists) { + return Error(http.StatusConflict, "username already taken") + } + if errors.Is(err, service.ErrInvalidToken) { + return Error(http.StatusUnauthorized, "invalid or expired token") + } + return err + } + + return c.JSON(http.StatusOK, response.User{ + ID: user.ID, + Username: user.Username, + Address: user.Address, + }) +} + func (h *Handler) TryIdentify() echo.MiddlewareFunc { return h.UserIdentity(true) } diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index b8e5309..2a25d8c 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -16,6 +16,11 @@ type CreateSession struct { Password string `json:"password" required:"true"` } +type UpdateAccount struct { + Username *string `json:"username"` + Address *string `json:"address"` +} + type CreateContest struct { Title string `json:"title" required:"true"` Description string `json:"description"` diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 782efc9..2c4066f 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -38,8 +38,9 @@ type Role struct { } type User struct { - ID int `json:"id"` - Username string `json:"username"` + ID int `json:"id"` + Username string `json:"username"` + Address *string `json:"address,omitempty"` } type ContestDetailed struct { diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 0a8d0a4..5084b27 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -81,6 +81,7 @@ func (r *Router) InitRoutes() *echo.Echo { api.GET("/account", r.handler.GetAccount, r.handler.MustIdentify()) api.POST("/account", r.handler.CreateAccount) + api.PATCH("/account", r.handler.UpdateAccount, r.handler.MustIdentify()) api.POST("/session", r.handler.CreateSession) // DONE: make this endpoints as filter to general endpoint, like: diff --git a/internal/app/service/account.go b/internal/app/service/account.go index 727488e..65e5a14 100644 --- a/internal/app/service/account.go +++ b/internal/app/service/account.go @@ -94,3 +94,28 @@ func (s *AccountService) GetAccount(ctx context.Context, userID int) (*AccountIn Role: role, }, nil } + +func (s *AccountService) UpdateAccount(ctx context.Context, userID int, params models.UpdateUserParams) (*models.User, error) { + op := "service.AccountService.UpdateAccount" + + if params.Username != nil { + existingUser, err := s.repo.User.GetByUsername(ctx, *params.Username) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, fmt.Errorf("%s: failed to check username availability: %w", op, err) + } + + if err == nil && existingUser.ID != userID { + return nil, ErrUserAlreadyExists + } + } + + user, err := s.repo.User.UpdateUser(ctx, userID, params) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidToken + } + if err != nil { + return nil, fmt.Errorf("%s: failed to update user: %w", op, err) + } + + return &user, nil +} diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index fd4a6b0..74b2b40 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -87,7 +87,7 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest return 0, err } - address := w.Address.String() + address := w.Address().String() mnemonic := strings.Join(w.Mnemonic, " ") err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { diff --git a/internal/config/config.go b/internal/config/config.go index e6eb6f0..536ce91 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { Security Security `yaml:"security" env-required:"true"` Postgres Postgres `yaml:"postgres" env-required:"true"` Redis Redis `yaml:"redis" env-required:"true"` + Ton Ton `yaml:"ton" env-required:"true"` } type Server struct { @@ -51,6 +52,11 @@ type Redis struct { Db int `yaml:"db"` } +type Ton struct { + IsTestnet bool `yaml:"is_testnet"` + ConfigURL string `yaml:"config_url"` +} + // MustLoad loads config to a new Config instance and return it func MustLoad() *Config { _ = godotenv.Load() diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index 395561a..e74c429 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -78,7 +78,7 @@ func (a *App) Run() { repo := repository.New(db) brok := broker.New(rc) - tc, err := ton.NewClient(ctx) + tc, err := ton.NewClient(ctx, &a.config.Ton) if err != nil { slog.Error("ton: could not establish connection", sl.Err(err)) return diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index f5e2e96..5055345 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -18,6 +18,11 @@ type User struct { CreatedAt time.Time `db:"created_at"` } +type UpdateUserParams struct { + Username *string + Address *string +} + type Role struct { ID int `db:"id"` Name string `db:"name"` diff --git a/internal/storage/repository/postgres/user/user.go b/internal/storage/repository/postgres/user/user.go index 99e06b2..22843ea 100644 --- a/internal/storage/repository/postgres/user/user.go +++ b/internal/storage/repository/postgres/user/user.go @@ -77,6 +77,21 @@ func (p *Postgres) GetByID(ctx context.Context, id int) (models.User, error) { return user, err } +func (p *Postgres) GetByUsername(ctx context.Context, username string) (models.User, error) { + var user models.User + + query := `SELECT id, username, password_hash, role_id, address, created_at FROM users WHERE username = $1` + err := p.conn.QueryRow(ctx, query, username).Scan( + &user.ID, + &user.Username, + &user.PasswordHash, + &user.RoleID, + &user.Address, + &user.CreatedAt, + ) + return user, err +} + func (p *Postgres) GetRole(ctx context.Context, userID int) (models.Role, error) { var role models.Role @@ -112,3 +127,26 @@ func (p *Postgres) GetCreatedContestsCount(ctx context.Context, userID int) (int err := p.conn.QueryRow(ctx, query, userID).Scan(&count) return count, err } + +func (p *Postgres) UpdateUser(ctx context.Context, userID int, params models.UpdateUserParams) (models.User, error) { + var user models.User + + query := ` + UPDATE users + SET + username = COALESCE($2, username), + address = CASE WHEN $3::text IS NOT NULL THEN $3 ELSE address END + WHERE id = $1 + RETURNING id, username, password_hash, role_id, address, created_at + ` + + err := p.conn.QueryRow(ctx, query, userID, params.Username, params.Address).Scan( + &user.ID, + &user.Username, + &user.PasswordHash, + &user.RoleID, + &user.Address, + &user.CreatedAt, + ) + return user, err +} diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index 96c6bea..f4bf9e0 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -39,7 +39,6 @@ func New(pool *pgxpool.Pool) *Repository { } } -// TxRepository provides repository instances within a transaction type TxRepository struct { User User Contest Contest @@ -50,7 +49,6 @@ type TxRepository struct { Payment Payment } -// NewTxRepository creates repository instances that use the provided transaction func NewTxRepository(tx pgx.Tx) *TxRepository { return &TxRepository{ Contest: contest.New(tx), @@ -68,9 +66,11 @@ type User interface { Create(ctx context.Context, username string, passwordHash string) (models.User, error) Exists(ctx context.Context, username string) (bool, error) GetByID(ctx context.Context, id int) (models.User, error) + GetByUsername(ctx context.Context, username string) (models.User, error) GetRole(ctx context.Context, userID int) (models.Role, error) GetCreatedProblemsCount(ctx context.Context, userID int) (int, error) GetCreatedContestsCount(ctx context.Context, userID int) (int, error) + UpdateUser(ctx context.Context, userID int, params models.UpdateUserParams) (models.User, error) } type Contest interface { diff --git a/pkg/scheduler/scheduler.go b/pkg/scheduler/scheduler.go index c66a7ff..5ef13b2 100644 --- a/pkg/scheduler/scheduler.go +++ b/pkg/scheduler/scheduler.go @@ -32,7 +32,7 @@ func (s *Scheduler) Start(ctx context.Context) { ticker := time.NewTicker(s.interval) defer ticker.Stop() - slog.Info("scheduler: started", slog.Duration("interval", s.interval)) + slog.Info("scheduler: started", slog.String("interval", s.interval.String())) if err := s.task(ctx); err != nil { slog.Error("scheduler: task execution failed", sl.Err(err)) diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 7a66db1..4f2ea8b 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/voidcontests/api/internal/config" "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/liteclient" "github.com/xssnick/tonutils-go/tlb" @@ -14,12 +15,13 @@ import ( ) type Client struct { - api tonutils.APIClientWrapped + api tonutils.APIClientWrapped + testnet bool } -func NewClient(ctx context.Context) (*Client, error) { +func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { client := liteclient.NewConnectionPool() - err := client.AddConnectionsFromConfigUrl(ctx, "https://ton.org/testnet-global.config.json") + err := client.AddConnectionsFromConfigUrl(ctx, c.ConfigURL) if err != nil { return nil, err } @@ -35,14 +37,16 @@ func NewClient(ctx context.Context) (*Client, error) { api.SetTrustedBlock(block) return &Client{ - api: api, + api: api, + testnet: c.IsTestnet, }, nil } type Wallet struct { - Address *address.Address + address *address.Address Mnemonic []string Instance *wallet.Wallet + testnet bool } func (c *Client) CreateWallet() (*Wallet, error) { @@ -57,9 +61,10 @@ func (c *Client) WalletWithSeed(mnemonic string) (*Wallet, error) { } return &Wallet{ - Address: w.WalletAddress(), + address: w.WalletAddress(), Mnemonic: words, Instance: w, + testnet: c.testnet, }, nil } @@ -154,3 +159,7 @@ func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *addres return "", false } + +func (w *Wallet) Address() *address.Address { + return w.address.Testnet(w.testnet) +} From 2866e5bc3d36724655207b2afd495dffdfacc760 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 03:02:25 +0400 Subject: [PATCH 35/56] chore: remove materialized views --- migrations/000002_views.down.sql | 5 ++--- migrations/000002_views.up.sql | 10 ++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/migrations/000002_views.down.sql b/migrations/000002_views.down.sql index 7b2f28d..723b730 100644 --- a/migrations/000002_views.down.sql +++ b/migrations/000002_views.down.sql @@ -2,6 +2,5 @@ DROP VIEW IF EXISTS submissions_view; DROP VIEW IF EXISTS contest_problems_view; DROP VIEW IF EXISTS problem_statuses; DROP VIEW IF EXISTS problems_view; - -DROP MATERIALIZED VIEW IF EXISTS contests_view; -DROP MATERIALIZED VIEW IF EXISTS scores; +DROP VIEW IF EXISTS contests_view; +DROP VIEW IF EXISTS scores; diff --git a/migrations/000002_views.up.sql b/migrations/000002_views.up.sql index 182f9e4..37a5ec9 100644 --- a/migrations/000002_views.up.sql +++ b/migrations/000002_views.up.sql @@ -1,4 +1,4 @@ -CREATE MATERIALIZED VIEW scores AS +CREATE VIEW scores AS SELECT e.contest_id, u.id AS user_id, @@ -25,12 +25,8 @@ LEFT JOIN ( LEFT JOIN problems p ON s.problem_id = p.id GROUP BY e.contest_id, u.id, u.username; -CREATE UNIQUE INDEX idx_scores_contest_user ON scores(contest_id, user_id); -CREATE INDEX idx_scores_contest_id ON scores(contest_id); -CREATE INDEX idx_scores_user_id ON scores(user_id); - -CREATE MATERIALIZED VIEW contests_view AS +CREATE VIEW contests_view AS SELECT c.id, c.creator_id, @@ -53,8 +49,6 @@ JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id GROUP BY c.id, u.username; -CREATE UNIQUE INDEX idx_contests_view_id ON contests_view(id); - CREATE VIEW problems_view AS SELECT From 5055f26906ed7d7606d510327db13a524c82533f Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 16:39:14 +0400 Subject: [PATCH 36/56] chore: remove GET /entry --- internal/app/handler/contest.go | 19 ++ internal/app/handler/dto/response/response.go | 25 +-- internal/app/handler/entry.go | 45 +---- internal/app/router/router.go | 8 +- internal/app/service/contest.go | 85 +++++++++ internal/app/service/entry.go | 172 ------------------ internal/app/service/service.go | 2 - 7 files changed, 121 insertions(+), 235 deletions(-) delete mode 100644 internal/app/service/entry.go diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 1e38c83..f34b1d2 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -102,6 +102,25 @@ func (h *Handler) GetContestByID(c echo.Context) error { CreatedAt: contest.CreatedAt, } + if details.EntryDetails != nil { + entryDetails := details.EntryDetails + contestEntry := response.Entry{ + IsAdmitted: entryDetails.IsAdmitted, + Message: entryDetails.Message, + IsPaid: entryDetails.Entry.PaymentID != nil, + CreatedAt: entryDetails.Entry.CreatedAt, + } + + if entryDetails.Payment != nil { + contestEntry.Payment = &response.PaymentDetails{ + TxHash: entryDetails.Payment.TxHash, + CreatedAt: entryDetails.Payment.CreatedAt, + } + } + + cdetailed.Entry = &contestEntry + } + for i := range n { p := details.Problems[i] cdetailed.Problems[i] = response.ContestProblemListItem{ diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 2c4066f..acf8a34 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -60,11 +60,25 @@ type ContestDetailed struct { AwardDistributed bool `json:"award_distributed"` IsParticipant bool `json:"is_participant,omitempty"` SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` + Entry *Entry `json:"entry,omitempty"` Problems []ContestProblemListItem `json:"problems"` Prizes Prizes `json:"prizes"` CreatedAt time.Time `json:"created_at"` } +type Entry struct { + IsAdmitted bool `json:"is_admitted"` + Message string `json:"message,omitempty"` + IsPaid bool `json:"is_paid"` + Payment *PaymentDetails `json:"payment,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type PaymentDetails struct { + TxHash string `json:"tx_hash"` + CreatedAt time.Time `json:"created_at"` +} + type Prizes struct { Nanos uint64 `json:"ton_nanos"` } @@ -168,14 +182,3 @@ type TC struct { Input string `json:"input"` Output string `json:"output"` } - -type Entry struct { - ID int `json:"id"` - ContestID int `json:"contest_id"` - UserID int `json:"user_id"` - IsPaid bool `json:"is_paid"` - PaymentID int `json:"payment_id,omitempty"` - IsAdmitted bool `json:"is_admitted"` - Message string `json:"message,omitempty"` - CreatedAt time.Time `json:"created_at"` -} diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index 1194cc1..ee160a6 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/labstack/echo/v4" - "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/app/service" ) @@ -19,7 +18,7 @@ func (h *Handler) CreateEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - err := h.service.Entry.CreateEntry(ctx, contestID, claims.UserID) + err := h.service.Contest.CreateEntry(ctx, contestID, claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): @@ -37,45 +36,3 @@ func (h *Handler) CreateEntry(c echo.Context) error { return c.NoContent(http.StatusCreated) } - -func (h *Handler) GetEntry(c echo.Context) error { - ctx := c.Request().Context() - - claims, _ := ExtractClaims(c) - - contestID, ok := ExtractParamInt(c, "cid") - if !ok { - return Error(http.StatusBadRequest, "contest ID should be an integer") - } - - details, err := h.service.Entry.GetEntry(ctx, contestID, claims.UserID) - if err != nil { - switch { - case errors.Is(err, service.ErrEntryNotFound): - return Error(http.StatusNotFound, "entry not found") - case errors.Is(err, service.ErrContestNotFound): - return Error(http.StatusNotFound, "contest not found") - default: - return err - } - } - - entry := details.Entry - - // TODO: put an entire payment (?) - var pid int - if entry.PaymentID != nil { - pid = *entry.PaymentID - } - - return c.JSON(http.StatusOK, response.Entry{ - ID: entry.ID, - ContestID: entry.ContestID, - UserID: entry.UserID, - IsPaid: entry.PaymentID != nil, - PaymentID: pid, - IsAdmitted: details.IsAdmitted, - Message: details.Message, - CreatedAt: entry.CreatedAt, - }) -} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 5084b27..53065c3 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -84,11 +84,8 @@ func (r *Router) InitRoutes() *echo.Echo { api.PATCH("/account", r.handler.UpdateAccount, r.handler.MustIdentify()) api.POST("/session", r.handler.CreateSession) - // DONE: make this endpoints as filter to general endpoint, like: - // GET /contests?creator_id=69 - // GET /problems?writer_id=420 - api.GET("/creator/contests", r.handler.GetCreatedContests, r.handler.MustIdentify()) - api.GET("/creator/problems", r.handler.GetCreatedProblems, r.handler.MustIdentify()) + api.GET("/account/contests", r.handler.GetCreatedContests, r.handler.MustIdentify()) + api.GET("/account/problems", r.handler.GetCreatedProblems, r.handler.MustIdentify()) api.POST("/problems", r.handler.CreateProblem, r.handler.MustIdentify()) @@ -99,7 +96,6 @@ func (r *Router) InitRoutes() *echo.Echo { api.GET("/contests/:cid", r.handler.GetContestByID, r.handler.TryIdentify()) api.POST("/contests/:cid/entry", r.handler.CreateEntry, r.handler.MustIdentify()) - api.GET("/contests/:cid/entry", r.handler.GetEntry, r.handler.MustIdentify()) api.GET("/contests/:cid/leaderboard", r.handler.GetLeaderboard) api.GET("/contests/:cid/problems/:charcode", r.handler.GetContestProblem, r.handler.MustIdentify()) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 74b2b40..d7d1cd1 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -156,6 +156,7 @@ type ContestDetails struct { ProblemStatuses map[int]string WalletAddress string PrizeNanosTON uint64 + EntryDetails *EntryDetails } func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID int, authenticated bool) (*ContestDetails, error) { @@ -231,6 +232,12 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user } details.ProblemStatuses = statuses + entryDetails, err := s.getEntryDetails(ctx, entry, contest) + if err != nil { + return nil, fmt.Errorf("%s: failed to get entry details: %w", op, err) + } + details.EntryDetails = &entryDetails + return details, nil } @@ -293,3 +300,81 @@ func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limi Total: total, }, nil } + +type EntryDetails struct { + Entry models.Entry + IsAdmitted bool + Message string + Payment *models.Payment +} + +func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry, contest models.Contest) (EntryDetails, error) { + op := "service.ContestService.getEntryDetails" + + if entry.PaymentID != nil { + payment, err := s.repo.Payment.GetByID(ctx, *entry.PaymentID) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to get payment: %w", op, err) + } + + return EntryDetails{ + Entry: entry, + IsAdmitted: true, + Payment: &payment, + }, nil + } + + if contest.AwardType != award.Pool { + return EntryDetails{ + Entry: entry, + IsAdmitted: true, + }, nil + } + + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "payment required to participate in this contest", + }, nil +} + +func (s *ContestService) CreateEntry(ctx context.Context, contestID int, userID int) error { + op := "service.ContestService.CreateEntry" + + contest, err := s.repo.Contest.GetByID(ctx, contestID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrContestNotFound + } + if err != nil { + return fmt.Errorf("%s: failed to get contest: %w", op, err) + } + + entriesCount, err := s.repo.Contest.GetEntriesCount(ctx, contestID) + if err != nil { + return fmt.Errorf("%s: failed to get entries count: %w", op, err) + } + + if contest.MaxEntries != 0 && entriesCount >= contest.MaxEntries { + return ErrMaxSlotsReached + } + + now := time.Now() + if contest.EndTime.Before(now) || (contest.StartTime.Before(now) && !contest.AllowLateJoin) { + return ErrApplicationTimeOver + } + + _, err = s.repo.Entry.Get(ctx, contestID, userID) + if err == nil { + return ErrEntryAlreadyExists + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("%s: failed to check existing entry: %w", op, err) + } + + _, err = s.repo.Entry.Create(ctx, contestID, userID) + if err != nil { + return fmt.Errorf("%s: failed to create entry: %w", op, err) + } + + return nil +} diff --git a/internal/app/service/entry.go b/internal/app/service/entry.go deleted file mode 100644 index e774e4b..0000000 --- a/internal/app/service/entry.go +++ /dev/null @@ -1,172 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "math/big" - "time" - - "github.com/jackc/pgx/v5" - "github.com/voidcontests/api/internal/storage/models" - "github.com/voidcontests/api/internal/storage/models/award" - "github.com/voidcontests/api/internal/storage/repository" - "github.com/voidcontests/api/pkg/ton" - "github.com/xssnick/tonutils-go/address" - "github.com/xssnick/tonutils-go/tlb" -) - -type EntryService struct { - repo *repository.Repository - ton *ton.Client -} - -func NewEntryService(repo *repository.Repository, tc *ton.Client) *EntryService { - return &EntryService{ - repo: repo, - ton: tc, - } -} - -func (s *EntryService) CreateEntry(ctx context.Context, contestID int, userID int) error { - op := "service.EntryService.CreateEntry" - - contest, err := s.repo.Contest.GetByID(ctx, contestID) - if errors.Is(err, pgx.ErrNoRows) { - return ErrContestNotFound - } - if err != nil { - return fmt.Errorf("%s: failed to get contest: %w", op, err) - } - - entriesCount, err := s.repo.Contest.GetEntriesCount(ctx, contestID) - if err != nil { - return fmt.Errorf("%s: failed to get entries count: %w", op, err) - } - - if contest.MaxEntries != 0 && entriesCount >= contest.MaxEntries { - return ErrMaxSlotsReached - } - - now := time.Now() - if contest.EndTime.Before(now) || (contest.StartTime.Before(now) && !contest.AllowLateJoin) { - return ErrApplicationTimeOver - } - - _, err = s.repo.Entry.Get(ctx, contestID, userID) - if err == nil { - return ErrEntryAlreadyExists - } - if !errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("%s: failed to check existing entry: %w", op, err) - } - - _, err = s.repo.Entry.Create(ctx, contestID, userID) - if err != nil { - return fmt.Errorf("%s: failed to create entry: %w", op, err) - } - - return nil -} - -type EntryDetails struct { - Entry models.Entry - IsAdmitted bool - Message string -} - -func (s *EntryService) GetEntry(ctx context.Context, contestID int, userID int) (EntryDetails, error) { - op := "service.EntryService.GetEntry" - - entry, err := s.repo.Entry.Get(ctx, contestID, userID) - if errors.Is(err, pgx.ErrNoRows) { - return EntryDetails{}, ErrEntryNotFound - } - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to get entry: %w", op, err) - } - - if entry.PaymentID != nil { - return EntryDetails{ - Entry: entry, - IsAdmitted: true, - }, nil - } - - contest, err := s.repo.Contest.GetByID(ctx, contestID) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to get contest: %w", op, err) - } - - if contest.AwardType != award.Pool { - return EntryDetails{ - Entry: entry, - IsAdmitted: true, - }, nil - } - - user, err := s.repo.User.GetByID(ctx, userID) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to get user: %w", op, err) - } - - if user.Address == nil || *user.Address == "" { - return EntryDetails{ - Entry: entry, - IsAdmitted: false, - Message: "connect wallet to your account, and pay entry price to contest's wallet", - }, nil - } - - // TODO: maybe this is a 5xx error - if contest.WalletID == nil { - return EntryDetails{ - Entry: entry, - IsAdmitted: false, - Message: "contest has ho wallet associated", - }, nil - } - - wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) - } - - from, err := address.ParseAddr(*user.Address) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to parse sender address: %w", op, err) - } - - to, err := address.ParseAddr(wallet.Address) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to parse recepient address: %w", op, err) - } - - amount := tlb.FromNanoTON(big.NewInt(int64(contest.EntryPriceTonNanos))) - - tx, exists := s.ton.LookupTx(ctx, from, to, amount) - if !exists { - return EntryDetails{ - Entry: entry, - IsAdmitted: false, - Message: "tx not found", - }, nil - } - - paymentID, err := s.repo.Payment.Create(ctx, tx, from.String(), to.String(), amount.Nano().Uint64(), true) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to create payment: %w", op, err) - } - - err = s.repo.Entry.SetPaymentID(ctx, entry.ID, paymentID) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to mark entry as paid: %w", op, err) - } - - entry.PaymentID = &paymentID - - return EntryDetails{ - Entry: entry, - IsAdmitted: true, - }, nil -} diff --git a/internal/app/service/service.go b/internal/app/service/service.go index a27a6e1..07f2a33 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -9,7 +9,6 @@ import ( type Service struct { Account *AccountService - Entry *EntryService Submission *SubmissionService Problem *ProblemService Contest *ContestService @@ -18,7 +17,6 @@ type Service struct { func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client) *Service { return &Service{ Account: NewAccountService(cfg, repo), - Entry: NewEntryService(repo, tc), Submission: NewSubmissionService(repo, broker), Problem: NewProblemService(repo), Contest: NewContestService(repo, tc), From 350753378a277726829122bda47034c10f098381 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 8 Nov 2025 19:10:34 +0400 Subject: [PATCH 37/56] chore: Improve contest response structure --- internal/app/handler/contest.go | 35 ++++++++-------- internal/app/handler/dto/response/response.go | 31 +++++++------- internal/app/service/contest.go | 41 ++++++++++--------- 3 files changed, 56 insertions(+), 51 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index f34b1d2..89811ab 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -76,39 +76,38 @@ func (h *Handler) GetContestByID(c echo.Context) error { contest := details.Contest n := len(details.Problems) cdetailed := response.ContestDetailed{ - ID: contest.ID, - Title: contest.Title, - Description: contest.Description, - AwardType: contest.AwardType, - EntryPriceTonNanos: contest.EntryPriceTonNanos, - Address: details.WalletAddress, + ID: contest.ID, + Title: contest.Title, + Description: contest.Description, Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, }, - Problems: make([]response.ContestProblemListItem, n, n), - Participants: contest.ParticipantsCount, + Address: details.WalletAddress, StartTime: contest.StartTime, EndTime: contest.EndTime, DurationMins: contest.DurationMins, + Participants: contest.ParticipantsCount, MaxEntries: contest.MaxEntries, - AllowLateJoin: contest.AllowLateJoin, - AwardDistributed: contest.DistributionPaymentID != nil, - IsParticipant: details.IsParticipant, - SubmissionDeadline: details.SubmissionDeadline, - Prizes: response.Prizes{ - Nanos: details.PrizeNanosTON, + IsRegistrationOpen: details.IsRegistrationOpen, + EntryPriceTonNanos: contest.EntryPriceTonNanos, + Awards: response.Awards{ + Kind: contest.AwardType, + Nanocoins: details.PrizeNanosTON, + IsDistributed: contest.DistributionPaymentID != nil, }, + Problems: make([]response.ContestProblemListItem, n, n), CreatedAt: contest.CreatedAt, } if details.EntryDetails != nil { entryDetails := details.EntryDetails contestEntry := response.Entry{ - IsAdmitted: entryDetails.IsAdmitted, - Message: entryDetails.Message, - IsPaid: entryDetails.Entry.PaymentID != nil, - CreatedAt: entryDetails.Entry.CreatedAt, + IsAdmitted: entryDetails.IsAdmitted, + SubmissionDeadline: entryDetails.SubmissionDeadline, + Message: entryDetails.Message, + IsPaid: entryDetails.Entry.PaymentID != nil, + CreatedAt: entryDetails.Entry.CreatedAt, } if entryDetails.Payment != nil { diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index acf8a34..1462048 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -47,31 +47,34 @@ type ContestDetailed struct { ID int `json:"id"` Title string `json:"title"` Description string `json:"description"` - AwardType string `json:"award_type"` - EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos"` - Address string `json:"address,omitempty"` Creator User `json:"creator"` + Address string `json:"address,omitempty"` StartTime time.Time `json:"start_time"` EndTime time.Time `json:"end_time"` DurationMins int `json:"duration_mins"` - MaxEntries int `json:"max_entries,omitempty"` Participants int `json:"participants"` - AllowLateJoin bool `json:"allow_late_join"` - AwardDistributed bool `json:"award_distributed"` - IsParticipant bool `json:"is_participant,omitempty"` - SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` + MaxEntries int `json:"max_entries,omitempty"` + IsRegistrationOpen bool `json:"is_registration_open"` + EntryPriceTonNanos uint64 `json:"entry_price_ton_nanos"` Entry *Entry `json:"entry,omitempty"` + Awards Awards `json:"awards"` Problems []ContestProblemListItem `json:"problems"` - Prizes Prizes `json:"prizes"` CreatedAt time.Time `json:"created_at"` } +type Awards struct { + Kind string `json:"kind"` + Nanocoins uint64 `json:"nanocoins"` + IsDistributed bool `json:"is_distributed"` +} + type Entry struct { - IsAdmitted bool `json:"is_admitted"` - Message string `json:"message,omitempty"` - IsPaid bool `json:"is_paid"` - Payment *PaymentDetails `json:"payment,omitempty"` - CreatedAt time.Time `json:"created_at"` + IsAdmitted bool `json:"is_admitted"` + SubmissionDeadline time.Time `json:"submission_deadline"` + Message string `json:"message,omitempty"` + IsPaid bool `json:"is_paid"` + Payment *PaymentDetails `json:"payment,omitempty"` + CreatedAt time.Time `json:"created_at"` } type PaymentDetails struct { diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index d7d1cd1..97e253e 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -150,9 +150,9 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest type ContestDetails struct { Contest models.Contest + IsRegistrationOpen bool Problems []models.Problem IsParticipant bool - SubmissionDeadline *time.Time ProblemStatuses map[int]string WalletAddress string PrizeNanosTON uint64 @@ -170,21 +170,21 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) } - now := time.Now() - // if contest.EndTime.Before(now) { - // if !authenticated || userID != contest.CreatorID { - // return nil, ErrContestFinished - // } - // } - problems, err := s.repo.Contest.GetProblemset(ctx, contestID) if err != nil { return nil, fmt.Errorf("%s: failed to get problemset: %w", op, err) } + now := time.Now() + + isRegistrationOpen := true + if contest.StartTime.Before(now) && contest.EndTime.After(now) && !contest.AllowLateJoin { + isRegistrationOpen = false + } details := &ContestDetails{ - Contest: contest, - Problems: problems, + IsRegistrationOpen: isRegistrationOpen, + Contest: contest, + Problems: problems, } if contest.WalletID != nil && (contest.AwardType == award.Pool || contest.AwardType == award.Sponsored) { @@ -221,11 +221,6 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user details.IsParticipant = true - _, deadline := CalculateSubmissionWindow(contest, entry) - if contest.StartTime.Before(now) { - details.SubmissionDeadline = &deadline - } - statuses, err := s.repo.Submission.GetProblemStatuses(ctx, entry.ID) if err != nil { return nil, fmt.Errorf("%s: failed to get problem statuses: %w", op, err) @@ -238,6 +233,13 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user } details.EntryDetails = &entryDetails + if details.EntryDetails != nil { + _, deadline := CalculateSubmissionWindow(contest, entry) + if contest.StartTime.Before(now) { + details.EntryDetails.SubmissionDeadline = deadline + } + } + return details, nil } @@ -302,10 +304,11 @@ func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limi } type EntryDetails struct { - Entry models.Entry - IsAdmitted bool - Message string - Payment *models.Payment + Entry models.Entry + IsAdmitted bool + SubmissionDeadline time.Time + Message string + Payment *models.Payment } func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry, contest models.Contest) (EntryDetails, error) { From 376e4bd8c258768f04dec5b9b5127ff32ab5d92e Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 9 Nov 2025 00:25:19 +0400 Subject: [PATCH 38/56] chore: check for entry paid on get contest --- .gitignore | 2 +- internal/app/distributor/distributor.go | 2 +- internal/app/service/contest.go | 56 +++++++++++++++++++++++++ pkg/ton/ton.go | 4 ++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 160b366..3f946fa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ build TODO buffer -migrations/999999_*.sql +migrations/data.up.sql diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index 2981a8e..ae186eb 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -74,7 +74,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) - paymentID, err := r.Payment.Create(ctx, tx, wallet.Address().String(), recepient.String(), amount.Nano().Uint64(), false) + paymentID, err := r.Payment.Create(ctx, tx, wallet.Address().String(), tc.GetAddressString(recepient), amount.Nano().Uint64(), false) if err != nil { return err } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 97e253e..6d3bc10 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -13,6 +13,7 @@ import ( "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ton" "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" ) type ContestService struct { @@ -334,10 +335,65 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry }, nil } + user, err := s.repo.User.GetByID(ctx, entry.UserID) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to get user: %w", op, err) + } + + if user.Address == nil { + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "address connected required to user account, to check payment", + }, nil + } + + from, err := address.ParseAddr(*user.Address) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to parse user address: %w", op, err) + } + + wallet, err := s.repo.Contest.GetWallet(ctx, contest.ID) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) + } + + to, err := address.ParseAddr(wallet.Address) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to parse user address: %w", op, err) + } + + amount := tlb.FromNanoTONU(contest.EntryPriceTonNanos) + tx, exists := s.ton.LookupTx(ctx, from, to, amount) + if !exists { + return EntryDetails{ + Entry: entry, + IsAdmitted: false, + Message: "payment required to participate in this contest", + }, nil + } + + pid, err := s.repo.Payment.Create(ctx, tx, s.ton.GetAddressString(from), wallet.Address, contest.EntryPriceTonNanos, true) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to create payment: %w", op, err) + } + + err = s.repo.Entry.SetPaymentID(ctx, entry.ID, pid) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to set payment ID for entry: %w", op, err) + } + entry.PaymentID = &pid + + payment, err := s.repo.Payment.GetByID(ctx, pid) + if err != nil { + return EntryDetails{}, fmt.Errorf("%s: failed to get payment by ID: %w", op, err) + } + return EntryDetails{ Entry: entry, IsAdmitted: false, Message: "payment required to participate in this contest", + Payment: &payment, }, nil } diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 4f2ea8b..13c0684 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -163,3 +163,7 @@ func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *addres func (w *Wallet) Address() *address.Address { return w.address.Testnet(w.testnet) } + +func (c *Client) GetAddressString(addr *address.Address) string { + return addr.Testnet(c.testnet).String() +} From e895509670b912a5338761dc522606a35c2decd5 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 9 Nov 2025 00:53:44 +0400 Subject: [PATCH 39/56] chore: rename leaderboard to scores --- internal/app/handler/contest.go | 27 ++++++----- internal/app/handler/dto/response/response.go | 7 +-- internal/app/router/router.go | 2 +- internal/app/service/contest.go | 45 +++++++++++-------- internal/storage/models/models.go | 2 +- .../repository/postgres/contest/contest.go | 12 ++--- internal/storage/repository/repository.go | 2 +- 7 files changed, 56 insertions(+), 41 deletions(-) diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 89811ab..e48a1b4 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -75,6 +75,15 @@ func (h *Handler) GetContestByID(c echo.Context) error { contest := details.Contest n := len(details.Problems) + awards := response.Awards{ + Kind: contest.AwardType, + Nanocoins: details.PrizeNanosTON, + IsDistributed: contest.DistributionPaymentID != nil, + } + if details.DistributionPayment != nil { + awards.DistributionTxHash = details.DistributionPayment.TxHash + } + cdetailed := response.ContestDetailed{ ID: contest.ID, Title: contest.Title, @@ -91,13 +100,9 @@ func (h *Handler) GetContestByID(c echo.Context) error { MaxEntries: contest.MaxEntries, IsRegistrationOpen: details.IsRegistrationOpen, EntryPriceTonNanos: contest.EntryPriceTonNanos, - Awards: response.Awards{ - Kind: contest.AwardType, - Nanocoins: details.PrizeNanosTON, - IsDistributed: contest.DistributionPaymentID != nil, - }, - Problems: make([]response.ContestProblemListItem, n, n), - CreatedAt: contest.CreatedAt, + Awards: awards, + Problems: make([]response.ContestProblemListItem, n, n), + CreatedAt: contest.CreatedAt, } if details.EntryDetails != nil { @@ -261,7 +266,7 @@ func (h *Handler) GetContests(c echo.Context) error { }) } -func (h *Handler) GetLeaderboard(c echo.Context) error { +func (h *Handler) GetScores(c echo.Context) error { ctx := c.Request().Context() contestID, ok := ExtractParamInt(c, "cid") @@ -279,7 +284,7 @@ func (h *Handler) GetLeaderboard(c echo.Context) error { offset = 0 } - result, err := h.service.Contest.GetLeaderboard(ctx, contestID, limit, offset) + result, err := h.service.Contest.GetScores(ctx, contestID, limit, offset) if err != nil { if errors.Is(err, service.ErrContestNotFound) { return Error(http.StatusNotFound, "contest not found") @@ -287,7 +292,7 @@ func (h *Handler) GetLeaderboard(c echo.Context) error { return err } - return c.JSON(http.StatusOK, response.Pagination[models.LeaderboardEntry]{ + return c.JSON(http.StatusOK, response.Pagination[models.ScoresEntry]{ Meta: response.Meta{ Total: result.Total, Limit: limit, @@ -295,6 +300,6 @@ func (h *Handler) GetLeaderboard(c echo.Context) error { HasNext: offset+limit < result.Total, HasPrev: offset > 0, }, - Items: result.Leaderboard, + Items: result.Scores, }) } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 1462048..1664be5 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -63,9 +63,10 @@ type ContestDetailed struct { } type Awards struct { - Kind string `json:"kind"` - Nanocoins uint64 `json:"nanocoins"` - IsDistributed bool `json:"is_distributed"` + Kind string `json:"kind"` + Nanocoins uint64 `json:"nanocoins"` + IsDistributed bool `json:"is_distributed"` + DistributionTxHash string `json:"distribution_tx_hash,omitempty"` } type Entry struct { diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 53065c3..93b6d53 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -96,7 +96,7 @@ func (r *Router) InitRoutes() *echo.Echo { api.GET("/contests/:cid", r.handler.GetContestByID, r.handler.TryIdentify()) api.POST("/contests/:cid/entry", r.handler.CreateEntry, r.handler.MustIdentify()) - api.GET("/contests/:cid/leaderboard", r.handler.GetLeaderboard) + api.GET("/contests/:cid/scores", r.handler.GetScores) api.GET("/contests/:cid/problems/:charcode", r.handler.GetContestProblem, r.handler.MustIdentify()) api.GET("/contests/:cid/problems/:charcode/submissions", r.handler.GetSubmissions, r.handler.MustIdentify()) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 6d3bc10..53f2a1d 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -150,14 +150,15 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest } type ContestDetails struct { - Contest models.Contest - IsRegistrationOpen bool - Problems []models.Problem - IsParticipant bool - ProblemStatuses map[int]string - WalletAddress string - PrizeNanosTON uint64 - EntryDetails *EntryDetails + Contest models.Contest + IsRegistrationOpen bool + Problems []models.Problem + IsParticipant bool + ProblemStatuses map[int]string + WalletAddress string + PrizeNanosTON uint64 + EntryDetails *EntryDetails + DistributionPayment *models.Payment } func (s *ContestService) GetContestByID(ctx context.Context, contestID int, userID int, authenticated bool) (*ContestDetails, error) { @@ -208,6 +209,14 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user } } + if contest.DistributionPaymentID != nil { + payment, err := s.repo.Payment.GetByID(ctx, *contest.DistributionPaymentID) + if err != nil { + return nil, fmt.Errorf("%s: failed to get distribution payment: %w", op, err) + } + details.DistributionPayment = &payment + } + if !authenticated { return details, nil } @@ -277,13 +286,13 @@ func (s *ContestService) ListAllContests(ctx context.Context, limit, offset int, }, nil } -type LeaderboardResult struct { - Leaderboard []models.LeaderboardEntry - Total int +type ScoresResult struct { + Scores []models.ScoresEntry + Total int } -func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limit, offset int) (*LeaderboardResult, error) { - op := "service.ContestService.GetLeaderboard" +func (s *ContestService) GetScores(ctx context.Context, contestID int, limit, offset int) (*ScoresResult, error) { + op := "service.ContestService.GetScores" _, err := s.repo.Contest.GetByID(ctx, contestID) if errors.Is(err, pgx.ErrNoRows) { @@ -293,14 +302,14 @@ func (s *ContestService) GetLeaderboard(ctx context.Context, contestID int, limi return nil, fmt.Errorf("%s: failed to get contest: %w", op, err) } - leaderboard, total, err := s.repo.Contest.GetLeaderboard(ctx, contestID, limit, offset) + scores, total, err := s.repo.Contest.GetScores(ctx, contestID, limit, offset) if err != nil { - return nil, fmt.Errorf("%s: failed to get leaderboard: %w", op, err) + return nil, fmt.Errorf("%s: failed to get scores: %w", op, err) } - return &LeaderboardResult{ - Leaderboard: leaderboard, - Total: total, + return &ScoresResult{ + Scores: scores, + Total: total, }, nil } diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index 5055345..eab9712 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -140,7 +140,7 @@ type TestingReport struct { CreatedAt time.Time `db:"created_at"` } -type LeaderboardEntry struct { +type ScoresEntry struct { UserID int `db:"user_id" json:"user_id"` Username string `db:"username" json:"username"` Points int `db:"points" json:"points"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index cb528fd..c3fa5f7 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -276,7 +276,7 @@ func (p *Postgres) GetWinnerID(ctx context.Context, contestID int) (int, error) return userID, err } -func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) { +func (p *Postgres) GetScores(ctx context.Context, contestID, limit, offset int) (scores []models.ScoresEntry, total int, err error) { query := ` SELECT user_id, username, points, COUNT(*) OVER() AS total FROM scores @@ -287,24 +287,24 @@ func (p *Postgres) GetLeaderboard(ctx context.Context, contestID, limit, offset rows, err := p.conn.Query(ctx, query, contestID, limit, offset) if err != nil { - return nil, 0, fmt.Errorf("leaderboard query failed: %w", err) + return nil, 0, fmt.Errorf("scores query failed: %w", err) } defer rows.Close() - leaderboard = make([]models.LeaderboardEntry, 0) + scores = make([]models.ScoresEntry, 0) for rows.Next() { - var entry models.LeaderboardEntry + var entry models.ScoresEntry if err := rows.Scan(&entry.UserID, &entry.Username, &entry.Points, &total); err != nil { return nil, 0, err } - leaderboard = append(leaderboard, entry) + scores = append(scores, entry) } if err := rows.Err(); err != nil { return nil, 0, err } - return leaderboard, total, nil + return scores, total, nil } func (p *Postgres) SetDistributionPaymentID(ctx context.Context, contestID int, paymentID int) error { diff --git a/internal/storage/repository/repository.go b/internal/storage/repository/repository.go index f4bf9e0..3ec2526 100644 --- a/internal/storage/repository/repository.go +++ b/internal/storage/repository/repository.go @@ -81,7 +81,7 @@ type Contest interface { GetWithCreatorID(ctx context.Context, creatorID int, limit, offset int) (contests []models.Contest, total int, err error) GetEntriesCount(ctx context.Context, contestID int) (int, error) IsTitleOccupied(ctx context.Context, title string) (bool, error) - GetLeaderboard(ctx context.Context, contestID, limit, offset int) (leaderboard []models.LeaderboardEntry, total int, err error) + GetScores(ctx context.Context, contestID, limit, offset int) (scores []models.ScoresEntry, total int, err error) GetWallet(ctx context.Context, walletID int) (models.Wallet, error) SetDistributionPaymentID(ctx context.Context, contestID int, paymentID int) error GetWithUndistributedAwards(ctx context.Context) ([]models.Contest, error) From 27cacfb014b5e1ccc07aef523b405d05924880b8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 9 Nov 2025 12:58:41 +0400 Subject: [PATCH 40/56] chore: ignore .diff files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3f946fa..a0cc20e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ TODO buffer migrations/data.up.sql + +.diff From de382b30437b5f06339f0d1b762f7ea550b660ca Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 9 Nov 2025 12:59:52 +0400 Subject: [PATCH 41/56] fix: proper ignore .diff files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a0cc20e..0965051 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ buffer migrations/data.up.sql -.diff +*.diff From db1ff41ae1ab1ad134b36cfc36871d6c23aa6206 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 9 Nov 2025 21:46:13 +0400 Subject: [PATCH 42/56] chore: add address to account/user response --- internal/app/distributor/distributor.go | 4 ++-- internal/app/handler/account.go | 1 + internal/app/handler/contest.go | 4 ++++ internal/app/handler/dto/response/response.go | 7 ++++--- internal/app/handler/problem.go | 3 +++ internal/app/service/contest.go | 4 ++-- internal/storage/models/models.go | 4 +++- .../repository/postgres/contest/contest.go | 20 +++++++++---------- .../repository/postgres/problem/problem.go | 16 +++++++-------- migrations/000001_tables.up.sql | 4 ++-- migrations/000002_views.up.sql | 5 ++++- 11 files changed, 43 insertions(+), 29 deletions(-) diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index ae186eb..70641a4 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -51,11 +51,11 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc return err } - if winner.Address == nil { + if winner.Address == "" { return fmt.Errorf("user has no wallet") } - recepient, err := address.ParseAddr(*winner.Address) + recepient, err := address.ParseAddr(winner.Address) if err != nil { return err } diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 2944ef6..8eb9640 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -76,6 +76,7 @@ func (h *Handler) GetAccount(c echo.Context) error { return c.JSON(http.StatusOK, response.Account{ ID: accountInfo.User.ID, Username: accountInfo.User.Username, + Address: accountInfo.User.Address, Role: response.Role{ Name: accountInfo.Role.Name, CreatedProblemsLimit: accountInfo.Role.CreatedProblemsLimit, diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index e48a1b4..88ba159 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -91,6 +91,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, + Address: contest.CreatorAddress, }, Address: details.WalletAddress, StartTime: contest.StartTime, @@ -133,6 +134,7 @@ func (h *Handler) GetContestByID(c echo.Context) error { Writer: response.User{ ID: p.WriterID, Username: p.WriterUsername, + Address: p.WriterAddress, }, Title: p.Title, Difficulty: p.Difficulty, @@ -174,6 +176,7 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, + Address: contest.CreatorAddress, }, Title: contest.Title, AwardType: contest.AwardType, @@ -239,6 +242,7 @@ func (h *Handler) GetContests(c echo.Context) error { Creator: response.User{ ID: contest.CreatorID, Username: contest.CreatorUsername, + Address: contest.CreatorAddress, }, Title: contest.Title, AwardType: contest.AwardType, diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index 1664be5..f907c45 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -28,6 +28,7 @@ type Token struct { type Account struct { ID int `json:"id"` Username string `json:"username"` + Address string `json:"address,omitempty"` Role Role `json:"role"` } @@ -38,9 +39,9 @@ type Role struct { } type User struct { - ID int `json:"id"` - Username string `json:"username"` - Address *string `json:"address,omitempty"` + ID int `json:"id"` + Username string `json:"username"` + Address string `json:"address,omitempty"` } type ContestDetailed struct { diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 682bda4..3f7f491 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -86,6 +86,7 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { Writer: response.User{ ID: p.WriterID, Username: p.WriterUsername, + Address: p.WriterAddress, }, } } @@ -160,6 +161,7 @@ func (h *Handler) GetContestProblem(c echo.Context) error { Writer: response.User{ ID: p.WriterID, Username: p.WriterUsername, + Address: p.WriterAddress, }, } @@ -215,6 +217,7 @@ func (h *Handler) GetProblemByID(c echo.Context) error { Writer: response.User{ ID: problem.WriterID, Username: problem.WriterUsername, + Address: problem.WriterAddress, }, } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 53f2a1d..cbd925a 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -349,7 +349,7 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry return EntryDetails{}, fmt.Errorf("%s: failed to get user: %w", op, err) } - if user.Address == nil { + if user.Address == "" { return EntryDetails{ Entry: entry, IsAdmitted: false, @@ -357,7 +357,7 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry }, nil } - from, err := address.ParseAddr(*user.Address) + from, err := address.ParseAddr(user.Address) if err != nil { return EntryDetails{}, fmt.Errorf("%s: failed to parse user address: %w", op, err) } diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index eab9712..ba7f4df 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -14,7 +14,7 @@ type User struct { Username string `db:"username"` PasswordHash string `db:"password_hash"` RoleID int `db:"role_id"` - Address *string `db:"address"` + Address string `db:"address"` CreatedAt time.Time `db:"created_at"` } @@ -36,6 +36,7 @@ type Contest struct { ID int `db:"id"` CreatorID int `db:"creator_id"` CreatorUsername string `db:"creator_username"` + CreatorAddress string `db:"creator_address"` Title string `db:"title"` Description string `db:"description"` AwardType string `db:"award_type"` @@ -83,6 +84,7 @@ type Problem struct { Charcode string `db:"charcode"` WriterID int `db:"writer_id"` WriterUsername string `db:"writer_username"` + WriterAddress string `db:"writer_address"` Title string `db:"title"` Statement string `db:"statement"` Difficulty string `db:"difficulty"` diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index c3fa5f7..c6b2d5c 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -62,12 +62,12 @@ func (p *Postgres) GetByID(ctx context.Context, contestID int) (models.Contest, var contest models.Contest query := ` SELECT - id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, + id, creator_id, creator_username, creator_address, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view WHERE id = $1` err := p.conn.QueryRow(ctx, query, contestID).Scan( - &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.Title, &contest.Description, &contest.AwardType, &contest.EntryPriceTonNanos, &contest.StartTime, + &contest.ID, &contest.CreatorID, &contest.CreatorUsername, &contest.CreatorAddress, &contest.Title, &contest.Description, &contest.AwardType, &contest.EntryPriceTonNanos, &contest.StartTime, &contest.EndTime, &contest.DurationMins, &contest.MaxEntries, &contest.AllowLateJoin, &contest.WalletID, &contest.DistributionPaymentID, &contest.ParticipantsCount, &contest.CreatedAt) return contest, err @@ -87,7 +87,7 @@ WHERE w.id = $1` func (p *Postgres) GetProblemset(ctx context.Context, contestID int) ([]models.Problem, error) { query := ` SELECT - problem_id, charcode, writer_id, writer_username, title, statement, + problem_id, charcode, writer_id, writer_username, writer_address, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM contest_problems_view WHERE contest_id = $1 ORDER BY charcode ASC` @@ -101,7 +101,7 @@ WHERE contest_id = $1 ORDER BY charcode ASC` var problems []models.Problem for rows.Next() { var problem models.Problem - if err := rows.Scan(&problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt); err != nil { + if err := rows.Scan(&problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.WriterAddress, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt); err != nil { return nil, err } problems = append(problems, problem) @@ -139,7 +139,7 @@ func (p *Postgres) ListAll(ctx context.Context, limit int, offset int, filters m query := fmt.Sprintf(` SELECT - id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, + id, creator_id, creator_username, creator_address, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view %s @@ -181,7 +181,7 @@ LIMIT $1 OFFSET $2 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.CreatorAddress, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { @@ -209,7 +209,7 @@ func (p *Postgres) GetWithCreatorID(ctx context.Context, creatorID int, limit, o batch := &pgx.Batch{} batch.Queue(` SELECT - id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, + id, creator_id, creator_username, creator_address, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view WHERE creator_id = $1 @@ -231,7 +231,7 @@ LIMIT $2 OFFSET $3 for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.CreatorAddress, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { @@ -319,7 +319,7 @@ func (p *Postgres) SetDistributionPaymentID(ctx context.Context, contestID int, func (p *Postgres) GetWithUndistributedAwards(ctx context.Context) ([]models.Contest, error) { query := ` SELECT - id, creator_id, creator_username, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, + id, creator_id, creator_username, creator_address, title, description, award_type, entry_price_ton_nanos, start_time, end_time, duration_mins, max_entries, allow_late_join, wallet_id, distribution_payment_id, participants_count, created_at FROM contests_view WHERE distribution_payment_id IS NULL AND end_time < now() AND award_type <> 'no' @@ -335,7 +335,7 @@ ORDER BY end_time ASC` for rows.Next() { var c models.Contest if err := rows.Scan( - &c.ID, &c.CreatorID, &c.CreatorUsername, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, + &c.ID, &c.CreatorID, &c.CreatorUsername, &c.CreatorAddress, &c.Title, &c.Description, &c.AwardType, &c.EntryPriceTonNanos, &c.StartTime, &c.EndTime, &c.DurationMins, &c.MaxEntries, &c.AllowLateJoin, &c.WalletID, &c.DistributionPaymentID, &c.ParticipantsCount, &c.CreatedAt, ); err != nil { diff --git a/internal/storage/repository/postgres/problem/problem.go b/internal/storage/repository/postgres/problem/problem.go index 07c8a27..57da032 100644 --- a/internal/storage/repository/postgres/problem/problem.go +++ b/internal/storage/repository/postgres/problem/problem.go @@ -63,7 +63,7 @@ func (p *Postgres) AssociateTestCases(ctx context.Context, problemID int, tcs [] func (p *Postgres) Get(ctx context.Context, contestID int, charcode string) (models.Problem, error) { query := ` SELECT - problem_id, charcode, writer_id, writer_username, title, statement, + problem_id, charcode, writer_id, writer_username, writer_address, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM contest_problems_view WHERE contest_id = $1 AND charcode = $2` @@ -72,7 +72,7 @@ WHERE contest_id = $1 AND charcode = $2` var problem models.Problem err := row.Scan( - &problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, + &problem.ID, &problem.Charcode, &problem.WriterID, &problem.WriterUsername, &problem.WriterAddress, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, ) @@ -81,7 +81,7 @@ WHERE contest_id = $1 AND charcode = $2` func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, error) { query := `SELECT - id, writer_id, writer_username, title, statement, + id, writer_id, writer_username, writer_address, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM problems_view WHERE id = $1` @@ -90,7 +90,7 @@ func (p *Postgres) GetByID(ctx context.Context, problemID int) (models.Problem, var problem models.Problem err := row.Scan( - &problem.ID, &problem.WriterID, &problem.WriterUsername, &problem.Title, &problem.Statement, + &problem.ID, &problem.WriterID, &problem.WriterUsername, &problem.WriterAddress, &problem.Title, &problem.Statement, &problem.Difficulty, &problem.TimeLimitMS, &problem.MemoryLimitMB, &problem.Checker, &problem.CreatedAt, ) @@ -141,7 +141,7 @@ func (p *Postgres) GetTestCaseByID(ctx context.Context, testCaseID int) (models. func (p *Postgres) GetAll(ctx context.Context) ([]models.Problem, error) { query := ` SELECT - id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, + id, writer_id, writer_username, writer_address, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM problems_view` @@ -155,7 +155,7 @@ FROM problems_view` for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.WriterID, &p.WriterUsername, &p.Title, &p.Statement, &p.Difficulty, + &p.ID, &p.WriterID, &p.WriterUsername, &p.WriterAddress, &p.Title, &p.Statement, &p.Difficulty, &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, ); err != nil { return nil, err @@ -171,7 +171,7 @@ func (p *Postgres) GetWithWriterID(ctx context.Context, writerID int, limit, off batch.Queue(` SELECT - id, writer_id, writer_username, title, statement, difficulty, time_limit_ms, + id, writer_id, writer_username, writer_address, title, statement, difficulty, time_limit_ms, memory_limit_mb, checker, created_at FROM problems_view WHERE writer_id = $1 @@ -193,7 +193,7 @@ LIMIT $2 OFFSET $3 for rows.Next() { var p models.Problem if err := rows.Scan( - &p.ID, &p.WriterID, &p.WriterUsername, &p.Title, &p.Statement, &p.Difficulty, + &p.ID, &p.WriterID, &p.WriterUsername, &p.WriterAddress, &p.Title, &p.Statement, &p.Difficulty, &p.TimeLimitMS, &p.MemoryLimitMB, &p.Checker, &p.CreatedAt, ); err != nil { rows.Close() diff --git a/migrations/000001_tables.up.sql b/migrations/000001_tables.up.sql index 5289715..57fb821 100644 --- a/migrations/000001_tables.up.sql +++ b/migrations/000001_tables.up.sql @@ -18,12 +18,12 @@ CREATE TABLE users ( username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT, - address VARCHAR(48), + address VARCHAR(48) DEFAULT '' NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); CREATE INDEX idx_users_role_id ON users(role_id); -CREATE UNIQUE INDEX unique_user_address ON users(address) WHERE address IS NOT NULL; +CREATE UNIQUE INDEX unique_user_address ON users(address) WHERE address <> ''; -- TODO: encrypt the mnemonic before saving CREATE TABLE wallets ( diff --git a/migrations/000002_views.up.sql b/migrations/000002_views.up.sql index 37a5ec9..bcf9f77 100644 --- a/migrations/000002_views.up.sql +++ b/migrations/000002_views.up.sql @@ -31,6 +31,7 @@ SELECT c.id, c.creator_id, u.username AS creator_username, + u.address AS creator_address, c.title, c.description, c.award_type, @@ -47,7 +48,7 @@ SELECT FROM contests c JOIN users u ON u.id = c.creator_id LEFT JOIN entries e ON e.contest_id = c.id -GROUP BY c.id, u.username; +GROUP BY c.id, u.username, u.address; CREATE VIEW problems_view AS @@ -55,6 +56,7 @@ SELECT p.id, p.writer_id, u.username AS writer_username, + u.address AS writer_address, p.title, p.statement, p.difficulty, @@ -85,6 +87,7 @@ SELECT cp.contest_id, p.writer_id, u.username AS writer_username, + u.address AS writer_address, p.title, p.statement, p.difficulty, From 9c8f0adfae005433ffd43240a0a647f3a4c62f7b Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 12:14:24 +0400 Subject: [PATCH 43/56] chore: Introduce tonproof logic. again --- go.mod | 4 ++ go.sum | 8 +++ internal/app/handler/handler.go | 5 +- internal/app/handler/tonproof.go | 84 ++++++++++++++++++++++++++++++++ internal/app/router/router.go | 9 +++- internal/config/config.go | 11 ++++- internal/pkg/app/app.go | 34 ++++++++++++- pkg/ton/proof.go | 20 ++++++++ pkg/ton/tonconnect.go | 20 ++++++++ 9 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 internal/app/handler/tonproof.go create mode 100644 pkg/ton/proof.go create mode 100644 pkg/ton/tonconnect.go diff --git a/go.mod b/go.mod index b4104af..196b8f6 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.3 github.com/redis/go-redis/v9 v9.12.1 + github.com/tonkeeper/tongo v1.16.54 github.com/xssnick/tonutils-go v1.15.5 ) @@ -26,10 +27,13 @@ require ( github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/snksoft/crc v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.42.0 // indirect + golang.org/x/exp v0.0.0-20230116083435-1de6713980de // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/go.sum b/go.sum index 1103747..86ae40d 100644 --- a/go.sum +++ b/go.sum @@ -45,17 +45,23 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae h1:7smdlrfdcZic4VfsGKD2ulWL804a4GVphr4s7WZxGiY= +github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s= 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/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/snksoft/crc v1.1.0 h1:HkLdI4taFlgGGG1KvsWMpz78PkOC9TkPVpTV/cuWn48= +github.com/snksoft/crc v1.1.0/go.mod h1:5/gUOsgAm7OmIhb6WJzw7w5g2zfJi4FrHYgGPdshE+A= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tonkeeper/tongo v1.16.54 h1:X8+VnC/gR/0+S1jlcY1hmvkL0wUrn5TEOyWL6uTHmd8= +github.com/tonkeeper/tongo v1.16.54/go.mod h1:MjgIgAytFarjCoVjMLjYEtpZNN1f2G/pnZhKjr28cWs= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -64,6 +70,8 @@ github.com/xssnick/tonutils-go v1.15.5 h1:yAcHnDaY5QW0aIQE47lT0PuDhhHYE+N+NyZssd github.com/xssnick/tonutils-go v1.15.5/go.mod h1:3/B8mS5IWLTd1xbGbFbzRem55oz/Q86HG884bVsTqZ8= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0= +golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index 3853d5f..4c3fb9f 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/labstack/echo/v4" + "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/jwt" @@ -18,14 +19,16 @@ type Handler struct { repo *repository.Repository broker broker.Broker service *service.Service + tcs *tonconnect.Server } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Handler { +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, tcs *tonconnect.Server) *Handler { return &Handler{ config: c, repo: r, broker: b, service: service.New(&c.Security, r, b, tc), + tcs: tcs, } } diff --git a/internal/app/handler/tonproof.go b/internal/app/handler/tonproof.go new file mode 100644 index 0000000..8b0134d --- /dev/null +++ b/internal/app/handler/tonproof.go @@ -0,0 +1,84 @@ +package handler + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/tonkeeper/tongo/tonconnect" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/pkg/ton" + "github.com/voidcontests/api/pkg/validate" + "github.com/xssnick/tonutils-go/address" +) + +// TODO: Wrap errors + +func (h *Handler) GeneratePayload(c echo.Context) error { + // 0 8 16 48 + // | random bits | expiration time | sha2 signature | + // 0 32 + // | payload | + + payload, err := h.tcs.GeneratePayload() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, map[string]any{ + "payload": payload, + }) +} + +func (h *Handler) CheckProof(c echo.Context) error { + ctx := c.Request().Context() + + claims, ok := ExtractClaims(c) + if !ok { + return Error(http.StatusUnauthorized, "user not authenticated") + } + + var tp ton.Proof + if err := validate.Bind(c, &tp); err != nil { + return Error(http.StatusBadRequest, "invalid request body") + } + + expectedNetwork := ton.MainnetID + if h.config.Ton.IsTestnet { + expectedNetwork = ton.TestnetID + } + if tp.Network != expectedNetwork { + return Error(http.StatusBadRequest, "network mismatch") + } + + proof := tonconnect.Proof{ + Address: tp.Address, + Proof: tonconnect.ProofData{ + Timestamp: tp.Proof.Timestamp, + Domain: tp.Proof.Domain.Value, + Signature: tp.Proof.Signature, + Payload: tp.Proof.Payload, + StateInit: tp.Proof.StateInit, + }, + } + + verified, _, err := h.tcs.CheckProof(ctx, &proof, h.tcs.CheckPayload, tonconnect.StaticDomain(tp.Proof.Domain.Value)) + if err != nil || !verified { + return Error(http.StatusUnauthorized, "tonproof verification failed") + } + + addr, err := address.ParseRawAddr(tp.Address) + if err != nil { + return err + } + + addrstr := addr.Testnet(h.config.Ton.IsTestnet).String() + + _, err = h.repo.User.UpdateUser(ctx, claims.UserID, models.UpdateUserParams{ + Address: &addrstr, + }) + if err != nil { + return err + } + + return c.NoContent(http.StatusOK) +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 93b6d53..d887503 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -7,6 +7,7 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/handler" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/lib/logger/sl" @@ -23,8 +24,8 @@ type Router struct { handler *handler.Handler } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Router { - h := handler.New(c, r, b, tc) +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, tcs *tonconnect.Server) *Router { + h := handler.New(c, r, b, tc, tcs) return &Router{config: c, handler: h} } @@ -79,6 +80,10 @@ func (r *Router) InitRoutes() *echo.Echo { { api.GET("/healthcheck", r.handler.Healthcheck) + tonproof := api.Group("/tonproof") + tonproof.POST("/payload", r.handler.GeneratePayload) + tonproof.POST("/check", r.handler.CheckProof, r.handler.MustIdentify()) + api.GET("/account", r.handler.GetAccount, r.handler.MustIdentify()) api.POST("/account", r.handler.CreateAccount) api.PATCH("/account", r.handler.UpdateAccount, r.handler.MustIdentify()) diff --git a/internal/config/config.go b/internal/config/config.go index 536ce91..e4e8823 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -53,8 +53,15 @@ type Redis struct { } type Ton struct { - IsTestnet bool `yaml:"is_testnet"` - ConfigURL string `yaml:"config_url"` + IsTestnet bool `yaml:"is_testnet"` + ConfigURL string `yaml:"config_url"` + Proof TonProof `yaml:"proof"` +} + +type TonProof struct { + PayloadSignatureKey string `yaml:"payload_signature_key" env-required:"true"` + PayloadLifetime time.Duration `yaml:"payload_lifetime" env-default:"600s"` + ProofLifetime time.Duration `yaml:"proof_lifetime" env-default:"600s"` } // MustLoad loads config to a new Config instance and return it diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index e74c429..c65d4e3 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -12,6 +12,8 @@ import ( "time" "github.com/redis/go-redis/v9" + "github.com/tonkeeper/tongo/liteapi" + "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/distributor" "github.com/voidcontests/api/internal/app/router" "github.com/voidcontests/api/internal/config" @@ -84,9 +86,37 @@ func (a *App) Run() { return } - slog.Info("ton: ok") + if a.config.Ton.IsTestnet { + slog.Info("ton: ok (testnet)") + } else { + slog.Info("ton: ok (mainnet)") + } + + var tcc *liteapi.Client + if a.config.Ton.IsTestnet { + tcc = ton.Testnet() + } else { + tcc = ton.Mainnet() + } + + tcs, err := tonconnect.NewTonConnect( + tcc, + a.config.Ton.Proof.PayloadSignatureKey, + tonconnect.WithLifeTimePayload(int64(a.config.Ton.Proof.PayloadLifetime.Seconds())), + tonconnect.WithLifeTimeProof(int64(a.config.Ton.Proof.ProofLifetime.Seconds())), + ) + if err != nil { + slog.Error("tonconnect: could not initialize", sl.Err(err)) + return + } + + if a.config.Ton.IsTestnet { + slog.Info("tonconnect: ok (testnet)") + } else { + slog.Info("tonconnect: ok (mainnet)") + } - r := router.New(a.config, repo, brok, tc) + r := router.New(a.config, repo, brok, tc, tcs) server := &http.Server{ Addr: a.config.Server.Address, diff --git a/pkg/ton/proof.go b/pkg/ton/proof.go new file mode 100644 index 0000000..75f4605 --- /dev/null +++ b/pkg/ton/proof.go @@ -0,0 +1,20 @@ +package ton + +type Proof struct { + Address string `json:"address" required:"true"` + Network string `json:"network" required:"true"` + Proof ProofData `json:"proof" required:"true"` +} + +type ProofData struct { + Timestamp int64 `json:"timestamp" required:"true"` + Domain Domain `json:"domain" required:"true"` + Signature string `json:"signature" required:"true"` + Payload string `json:"payload" required:"true"` + StateInit string `json:"state_init" required:"true"` +} + +type Domain struct { + LengthBytes int `json:"lengthBytes" required:"true"` + Value string `json:"value" required:"true"` +} diff --git a/pkg/ton/tonconnect.go b/pkg/ton/tonconnect.go new file mode 100644 index 0000000..c2dbf19 --- /dev/null +++ b/pkg/ton/tonconnect.go @@ -0,0 +1,20 @@ +package ton + +import ( + "github.com/tonkeeper/tongo/liteapi" +) + +const ( + MainnetID = "-239" + TestnetID = "-3" +) + +func Mainnet() *liteapi.Client { + client, _ := liteapi.NewClientWithDefaultMainnet() + return client +} + +func Testnet() *liteapi.Client { + client, _ := liteapi.NewClientWithDefaultTestnet() + return client +} From 4edd6bc56d74ef518de15f07ce47263ca56b6c00 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 12:52:58 +0400 Subject: [PATCH 44/56] chore: replace liteapi with tonutils --- internal/pkg/app/app.go | 11 ++--------- pkg/ton/executor.go | 44 +++++++++++++++++++++++++++++++++++++++++ pkg/ton/proof.go | 20 +++++++++---------- pkg/ton/ton.go | 9 +++++++++ pkg/ton/tonconnect.go | 20 ------------------- 5 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 pkg/ton/executor.go delete mode 100644 pkg/ton/tonconnect.go diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index c65d4e3..a020ab0 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -12,7 +12,6 @@ import ( "time" "github.com/redis/go-redis/v9" - "github.com/tonkeeper/tongo/liteapi" "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/distributor" "github.com/voidcontests/api/internal/app/router" @@ -92,15 +91,9 @@ func (a *App) Run() { slog.Info("ton: ok (mainnet)") } - var tcc *liteapi.Client - if a.config.Ton.IsTestnet { - tcc = ton.Testnet() - } else { - tcc = ton.Mainnet() - } - + executor := ton.NewExecutorAdapter(tc.API()) tcs, err := tonconnect.NewTonConnect( - tcc, + executor, a.config.Ton.Proof.PayloadSignatureKey, tonconnect.WithLifeTimePayload(int64(a.config.Ton.Proof.PayloadLifetime.Seconds())), tonconnect.WithLifeTimeProof(int64(a.config.Ton.Proof.ProofLifetime.Seconds())), diff --git a/pkg/ton/executor.go b/pkg/ton/executor.go new file mode 100644 index 0000000..216b8c8 --- /dev/null +++ b/pkg/ton/executor.go @@ -0,0 +1,44 @@ +package ton + +import ( + "context" + + "github.com/tonkeeper/tongo/abi" + "github.com/tonkeeper/tongo/tlb" + "github.com/tonkeeper/tongo/ton" + "github.com/xssnick/tonutils-go/address" + tonutils "github.com/xssnick/tonutils-go/ton" +) + +// TODO: document this package, because liteapi is shit + +type ExecutorAdapter struct { + api tonutils.APIClientWrapped +} + +func NewExecutorAdapter(api tonutils.APIClientWrapped) abi.Executor { + return &ExecutorAdapter{api: api} +} + +func (e *ExecutorAdapter) RunSmcMethodByID(ctx context.Context, accountID ton.AccountID, methodID int, params tlb.VmStack) (uint32, tlb.VmStack, error) { + rawAddr := accountID.ToRaw() + addr, err := address.ParseAddr(rawAddr) + if err != nil { + return 0, tlb.VmStack{}, err + } + + block, err := e.api.CurrentMasterchainInfo(ctx) + if err != nil { + return 0, tlb.VmStack{}, err + } + + var tuParams []interface{} + + methodName := "" + _, err = e.api.RunGetMethod(ctx, block, addr, methodName, tuParams...) + if err != nil { + return 0, tlb.VmStack{}, err + } + + return 0, tlb.VmStack{}, nil +} diff --git a/pkg/ton/proof.go b/pkg/ton/proof.go index 75f4605..77f4681 100644 --- a/pkg/ton/proof.go +++ b/pkg/ton/proof.go @@ -1,20 +1,20 @@ package ton type Proof struct { - Address string `json:"address" required:"true"` - Network string `json:"network" required:"true"` - Proof ProofData `json:"proof" required:"true"` + Address string `json:"address"` + Network string `json:"network"` + Proof ProofData `json:"proof"` } type ProofData struct { - Timestamp int64 `json:"timestamp" required:"true"` - Domain Domain `json:"domain" required:"true"` - Signature string `json:"signature" required:"true"` - Payload string `json:"payload" required:"true"` - StateInit string `json:"state_init" required:"true"` + Timestamp int64 `json:"timestamp"` + Domain Domain `json:"domain"` + Signature string `json:"signature"` + Payload string `json:"payload"` + StateInit string `json:"state_init"` } type Domain struct { - LengthBytes int `json:"lengthBytes" required:"true"` - Value string `json:"value" required:"true"` + LengthBytes int `json:"lengthBytes"` + Value string `json:"value"` } diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 13c0684..49dee1a 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -14,11 +14,20 @@ import ( "github.com/xssnick/tonutils-go/tvm/cell" ) +const ( + MainnetID = "-239" + TestnetID = "-3" +) + type Client struct { api tonutils.APIClientWrapped testnet bool } +func (c *Client) API() tonutils.APIClientWrapped { + return c.api +} + func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { client := liteclient.NewConnectionPool() err := client.AddConnectionsFromConfigUrl(ctx, c.ConfigURL) diff --git a/pkg/ton/tonconnect.go b/pkg/ton/tonconnect.go deleted file mode 100644 index c2dbf19..0000000 --- a/pkg/ton/tonconnect.go +++ /dev/null @@ -1,20 +0,0 @@ -package ton - -import ( - "github.com/tonkeeper/tongo/liteapi" -) - -const ( - MainnetID = "-239" - TestnetID = "-3" -) - -func Mainnet() *liteapi.Client { - client, _ := liteapi.NewClientWithDefaultMainnet() - return client -} - -func Testnet() *liteapi.Client { - client, _ := liteapi.NewClientWithDefaultTestnet() - return client -} From 9084169ceead1db08adc140151d5b39e0c1eaee4 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 15:21:35 +0400 Subject: [PATCH 45/56] chore: move implementation of abi.Executor to ton.Client --- internal/app/handler/dto/request/request.go | 19 +++++++++++++++++++ internal/app/handler/tonproof.go | 3 ++- internal/pkg/app/app.go | 3 +-- pkg/ton/executor.go | 19 ++++--------------- pkg/ton/proof.go | 20 -------------------- pkg/ton/ton.go | 8 ++++---- 6 files changed, 30 insertions(+), 42 deletions(-) delete mode 100644 pkg/ton/proof.go diff --git a/internal/app/handler/dto/request/request.go b/internal/app/handler/dto/request/request.go index 2a25d8c..517eac0 100644 --- a/internal/app/handler/dto/request/request.go +++ b/internal/app/handler/dto/request/request.go @@ -48,3 +48,22 @@ type CreateSubmission struct { Code string `json:"code"` Language string `json:"language"` } + +type TonProof struct { + Address string `json:"address"` + Network string `json:"network"` + Proof ProofData `json:"proof"` +} + +type ProofData struct { + Timestamp int64 `json:"timestamp"` + Domain Domain `json:"domain"` + Signature string `json:"signature"` + Payload string `json:"payload"` + StateInit string `json:"state_init"` +} + +type Domain struct { + LengthBytes int `json:"lengthBytes"` + Value string `json:"value"` +} diff --git a/internal/app/handler/tonproof.go b/internal/app/handler/tonproof.go index 8b0134d..5b482c9 100644 --- a/internal/app/handler/tonproof.go +++ b/internal/app/handler/tonproof.go @@ -5,6 +5,7 @@ import ( "github.com/labstack/echo/v4" "github.com/tonkeeper/tongo/tonconnect" + "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/pkg/ton" "github.com/voidcontests/api/pkg/validate" @@ -37,7 +38,7 @@ func (h *Handler) CheckProof(c echo.Context) error { return Error(http.StatusUnauthorized, "user not authenticated") } - var tp ton.Proof + var tp request.TonProof if err := validate.Bind(c, &tp); err != nil { return Error(http.StatusBadRequest, "invalid request body") } diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index a020ab0..e4cc61e 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -91,9 +91,8 @@ func (a *App) Run() { slog.Info("ton: ok (mainnet)") } - executor := ton.NewExecutorAdapter(tc.API()) tcs, err := tonconnect.NewTonConnect( - executor, + tc, a.config.Ton.Proof.PayloadSignatureKey, tonconnect.WithLifeTimePayload(int64(a.config.Ton.Proof.PayloadLifetime.Seconds())), tonconnect.WithLifeTimeProof(int64(a.config.Ton.Proof.ProofLifetime.Seconds())), diff --git a/pkg/ton/executor.go b/pkg/ton/executor.go index 216b8c8..f9adb86 100644 --- a/pkg/ton/executor.go +++ b/pkg/ton/executor.go @@ -3,31 +3,20 @@ package ton import ( "context" - "github.com/tonkeeper/tongo/abi" "github.com/tonkeeper/tongo/tlb" "github.com/tonkeeper/tongo/ton" "github.com/xssnick/tonutils-go/address" - tonutils "github.com/xssnick/tonutils-go/ton" ) -// TODO: document this package, because liteapi is shit - -type ExecutorAdapter struct { - api tonutils.APIClientWrapped -} - -func NewExecutorAdapter(api tonutils.APIClientWrapped) abi.Executor { - return &ExecutorAdapter{api: api} -} - -func (e *ExecutorAdapter) RunSmcMethodByID(ctx context.Context, accountID ton.AccountID, methodID int, params tlb.VmStack) (uint32, tlb.VmStack, error) { +// RunSmcMethodByID is an implementation of `abi.Executor` interface for `tonconnect.NewTonConnect` function +func (c *Client) RunSmcMethodByID(ctx context.Context, accountID ton.AccountID, methodID int, params tlb.VmStack) (uint32, tlb.VmStack, error) { rawAddr := accountID.ToRaw() addr, err := address.ParseAddr(rawAddr) if err != nil { return 0, tlb.VmStack{}, err } - block, err := e.api.CurrentMasterchainInfo(ctx) + block, err := c.api.CurrentMasterchainInfo(ctx) if err != nil { return 0, tlb.VmStack{}, err } @@ -35,7 +24,7 @@ func (e *ExecutorAdapter) RunSmcMethodByID(ctx context.Context, accountID ton.Ac var tuParams []interface{} methodName := "" - _, err = e.api.RunGetMethod(ctx, block, addr, methodName, tuParams...) + _, err = c.api.RunGetMethod(ctx, block, addr, methodName, tuParams...) if err != nil { return 0, tlb.VmStack{}, err } diff --git a/pkg/ton/proof.go b/pkg/ton/proof.go deleted file mode 100644 index 77f4681..0000000 --- a/pkg/ton/proof.go +++ /dev/null @@ -1,20 +0,0 @@ -package ton - -type Proof struct { - Address string `json:"address"` - Network string `json:"network"` - Proof ProofData `json:"proof"` -} - -type ProofData struct { - Timestamp int64 `json:"timestamp"` - Domain Domain `json:"domain"` - Signature string `json:"signature"` - Payload string `json:"payload"` - StateInit string `json:"state_init"` -} - -type Domain struct { - LengthBytes int `json:"lengthBytes"` - Value string `json:"value"` -} diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 49dee1a..08bfd92 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -24,10 +24,6 @@ type Client struct { testnet bool } -func (c *Client) API() tonutils.APIClientWrapped { - return c.api -} - func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { client := liteclient.NewConnectionPool() err := client.AddConnectionsFromConfigUrl(ctx, c.ConfigURL) @@ -176,3 +172,7 @@ func (w *Wallet) Address() *address.Address { func (c *Client) GetAddressString(addr *address.Address) string { return addr.Testnet(c.testnet).String() } + +func (c *Client) API() tonutils.APIClientWrapped { + return c.api +} From a4de79b2814387dcd748665a83abffd7732cbedb Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 15:41:34 +0400 Subject: [PATCH 46/56] chore: hide tonconnect.Server inside of ton.Client --- internal/app/handler/handler.go | 8 ++++---- internal/app/handler/tonproof.go | 4 ++-- internal/app/router/router.go | 5 ++--- internal/pkg/app/app.go | 30 ++++-------------------------- pkg/ton/ton.go | 28 +++++++++++++++++++++++----- 5 files changed, 35 insertions(+), 40 deletions(-) diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index 4c3fb9f..f44da71 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -5,7 +5,6 @@ import ( "strconv" "github.com/labstack/echo/v4" - "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/jwt" @@ -19,16 +18,17 @@ type Handler struct { repo *repository.Repository broker broker.Broker service *service.Service - tcs *tonconnect.Server + // temporary: + tc *ton.Client } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, tcs *tonconnect.Server) *Handler { +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Handler { return &Handler{ config: c, repo: r, broker: b, service: service.New(&c.Security, r, b, tc), - tcs: tcs, + tc: tc, } } diff --git a/internal/app/handler/tonproof.go b/internal/app/handler/tonproof.go index 5b482c9..9cfce7d 100644 --- a/internal/app/handler/tonproof.go +++ b/internal/app/handler/tonproof.go @@ -20,7 +20,7 @@ func (h *Handler) GeneratePayload(c echo.Context) error { // 0 32 // | payload | - payload, err := h.tcs.GeneratePayload() + payload, err := h.tc.TonConnect.GeneratePayload() if err != nil { return err } @@ -62,7 +62,7 @@ func (h *Handler) CheckProof(c echo.Context) error { }, } - verified, _, err := h.tcs.CheckProof(ctx, &proof, h.tcs.CheckPayload, tonconnect.StaticDomain(tp.Proof.Domain.Value)) + verified, _, err := h.tc.TonConnect.CheckProof(ctx, &proof, h.tc.TonConnect.CheckPayload, tonconnect.StaticDomain(tp.Proof.Domain.Value)) if err != nil || !verified { return Error(http.StatusUnauthorized, "tonproof verification failed") } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index d887503..f0ae8eb 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -7,7 +7,6 @@ import ( "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/handler" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/lib/logger/sl" @@ -24,8 +23,8 @@ type Router struct { handler *handler.Handler } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, tcs *tonconnect.Server) *Router { - h := handler.New(c, r, b, tc, tcs) +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Router { + h := handler.New(c, r, b, tc) return &Router{config: c, handler: h} } diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index e4cc61e..048343c 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -12,7 +12,6 @@ import ( "time" "github.com/redis/go-redis/v9" - "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/distributor" "github.com/voidcontests/api/internal/app/router" "github.com/voidcontests/api/internal/config" @@ -79,36 +78,15 @@ func (a *App) Run() { repo := repository.New(db) brok := broker.New(rc) - tc, err := ton.NewClient(ctx, &a.config.Ton) + tonc, err := ton.NewClient(ctx, &a.config.Ton) if err != nil { slog.Error("ton: could not establish connection", sl.Err(err)) return } - if a.config.Ton.IsTestnet { - slog.Info("ton: ok (testnet)") - } else { - slog.Info("ton: ok (mainnet)") - } - - tcs, err := tonconnect.NewTonConnect( - tc, - a.config.Ton.Proof.PayloadSignatureKey, - tonconnect.WithLifeTimePayload(int64(a.config.Ton.Proof.PayloadLifetime.Seconds())), - tonconnect.WithLifeTimeProof(int64(a.config.Ton.Proof.ProofLifetime.Seconds())), - ) - if err != nil { - slog.Error("tonconnect: could not initialize", sl.Err(err)) - return - } - - if a.config.Ton.IsTestnet { - slog.Info("tonconnect: ok (testnet)") - } else { - slog.Info("tonconnect: ok (mainnet)") - } + slog.Info("ton: ok", slog.Bool("is_testnet", a.config.Ton.IsTestnet)) - r := router.New(a.config, repo, brok, tc, tcs) + r := router.New(a.config, repo, brok, tonc) server := &http.Server{ Addr: a.config.Server.Address, @@ -131,7 +109,7 @@ func (a *App) Run() { slog.Info("api: started", slog.String("address", server.Addr)) interval := 1 * time.Minute - task := distributor.New(repo, tc) + task := distributor.New(repo, tonc) scheduler := scheduler.New(interval, task) go func() { diff --git a/pkg/ton/ton.go b/pkg/ton/ton.go index 08bfd92..1f32933 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/ton.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/config" "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/liteclient" @@ -20,8 +21,9 @@ const ( ) type Client struct { - api tonutils.APIClientWrapped - testnet bool + api tonutils.APIClientWrapped + testnet bool + TonConnect *tonconnect.Server } func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { @@ -32,7 +34,6 @@ func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { } unsafeAPI := tonutils.NewAPIClient(client, tonutils.ProofCheckPolicyUnsafe) - block, err := unsafeAPI.GetMasterchainInfo(ctx) if err != nil { return nil, err @@ -41,10 +42,27 @@ func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { api := tonutils.NewAPIClient(client, tonutils.ProofCheckPolicySecure).WithRetry() api.SetTrustedBlock(block) - return &Client{ + tc := &Client{ api: api, testnet: c.IsTestnet, - }, nil + } + + payloadLifetime := int64(c.Proof.PayloadLifetime.Seconds()) + proofLifetime := int64(c.Proof.ProofLifetime.Seconds()) + + tcserver, err := tonconnect.NewTonConnect( + tc, + c.Proof.PayloadSignatureKey, + tonconnect.WithLifeTimePayload(payloadLifetime), + tonconnect.WithLifeTimeProof(proofLifetime), + ) + if err != nil { + return nil, fmt.Errorf("tonconnect: can't initialize: %w", err) + } + + tc.TonConnect = tcserver + + return tc, nil } type Wallet struct { From e74632798165cb0c3eccfff52cec9ca4c7a2816b Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 16:01:12 +0400 Subject: [PATCH 47/56] chore: Move tonproof logic into service --- internal/app/handler/handler.go | 3 - internal/app/handler/tonproof.go | 50 +--------------- internal/app/service/errors.go | 3 + internal/app/service/service.go | 2 + internal/app/service/tonproof.go | 98 ++++++++++++++++++++++++++++++++ pkg/ton/{ton.go => client.go} | 41 +------------ pkg/ton/wallet.go | 52 +++++++++++++++++ 7 files changed, 160 insertions(+), 89 deletions(-) create mode 100644 internal/app/service/tonproof.go rename pkg/ton/{ton.go => client.go} (77%) create mode 100644 pkg/ton/wallet.go diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index f44da71..3853d5f 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -18,8 +18,6 @@ type Handler struct { repo *repository.Repository broker broker.Broker service *service.Service - // temporary: - tc *ton.Client } func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Handler { @@ -28,7 +26,6 @@ func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Cl repo: r, broker: b, service: service.New(&c.Security, r, b, tc), - tc: tc, } } diff --git a/internal/app/handler/tonproof.go b/internal/app/handler/tonproof.go index 9cfce7d..5a5b369 100644 --- a/internal/app/handler/tonproof.go +++ b/internal/app/handler/tonproof.go @@ -4,23 +4,12 @@ import ( "net/http" "github.com/labstack/echo/v4" - "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/app/handler/dto/request" - "github.com/voidcontests/api/internal/storage/models" - "github.com/voidcontests/api/pkg/ton" "github.com/voidcontests/api/pkg/validate" - "github.com/xssnick/tonutils-go/address" ) -// TODO: Wrap errors - func (h *Handler) GeneratePayload(c echo.Context) error { - // 0 8 16 48 - // | random bits | expiration time | sha2 signature | - // 0 32 - // | payload | - - payload, err := h.tc.TonConnect.GeneratePayload() + payload, err := h.service.TonProof.GeneratePayload() if err != nil { return err } @@ -43,42 +32,9 @@ func (h *Handler) CheckProof(c echo.Context) error { return Error(http.StatusBadRequest, "invalid request body") } - expectedNetwork := ton.MainnetID - if h.config.Ton.IsTestnet { - expectedNetwork = ton.TestnetID - } - if tp.Network != expectedNetwork { - return Error(http.StatusBadRequest, "network mismatch") - } - - proof := tonconnect.Proof{ - Address: tp.Address, - Proof: tonconnect.ProofData{ - Timestamp: tp.Proof.Timestamp, - Domain: tp.Proof.Domain.Value, - Signature: tp.Proof.Signature, - Payload: tp.Proof.Payload, - StateInit: tp.Proof.StateInit, - }, - } - - verified, _, err := h.tc.TonConnect.CheckProof(ctx, &proof, h.tc.TonConnect.CheckPayload, tonconnect.StaticDomain(tp.Proof.Domain.Value)) - if err != nil || !verified { - return Error(http.StatusUnauthorized, "tonproof verification failed") - } - - addr, err := address.ParseRawAddr(tp.Address) + err := h.service.TonProof.VerifyProofAndSetAddress(ctx, claims.UserID, tp) if err != nil { - return err - } - - addrstr := addr.Testnet(h.config.Ton.IsTestnet).String() - - _, err = h.repo.User.UpdateUser(ctx, claims.UserID, models.UpdateUserParams{ - Address: &addrstr, - }) - if err != nil { - return err + return Error(http.StatusUnauthorized, "tonproof verification failed") } return c.NoContent(http.StatusOK) diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index 4881dbf..02397de 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -37,4 +37,7 @@ var ( ErrSubmissionWindowClosed = errors.New("submission window is currently closed") ErrSubmissionNotFound = errors.New("submission not found") ErrInvalidCharcode = errors.New("problem's charcode couldn't be longer than 2 characters") + + // tonproof + ErrTonProofFailed = errors.New("tonproof verification failed") ) diff --git a/internal/app/service/service.go b/internal/app/service/service.go index 07f2a33..36604f0 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -12,6 +12,7 @@ type Service struct { Submission *SubmissionService Problem *ProblemService Contest *ContestService + TonProof *TonProofService } func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client) *Service { @@ -20,5 +21,6 @@ func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker Submission: NewSubmissionService(repo, broker), Problem: NewProblemService(repo), Contest: NewContestService(repo, tc), + TonProof: NewTonProofService(repo, tc), } } diff --git a/internal/app/service/tonproof.go b/internal/app/service/tonproof.go new file mode 100644 index 0000000..5eaf075 --- /dev/null +++ b/internal/app/service/tonproof.go @@ -0,0 +1,98 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + + "github.com/tonkeeper/tongo/tonconnect" + "github.com/voidcontests/api/internal/app/handler/dto/request" + "github.com/voidcontests/api/internal/lib/logger/sl" + "github.com/voidcontests/api/internal/storage/models" + "github.com/voidcontests/api/internal/storage/repository" + "github.com/voidcontests/api/pkg/ton" + "github.com/xssnick/tonutils-go/address" +) + +// TODO: Wrap errors + +type TonProofService struct { + repo *repository.Repository + ton *ton.Client + testnet bool +} + +func NewTonProofService(repo *repository.Repository, tc *ton.Client) *TonProofService { + return &TonProofService{ + repo: repo, + ton: tc, + testnet: tc.IsTestnet(), + } +} + +func (s *TonProofService) GeneratePayload() (string, error) { + op := "service.TonProofService.GeneratePayload" + + payload, err := s.ton.TonConnect.GeneratePayload() + if err != nil { + slog.Error("tonproof: failed to generate payload", slog.String("op", op), slog.String("error", err.Error())) + return "", fmt.Errorf("%s: %w", op, err) + } + + slog.Info("tonproof: payload generated", slog.String("payload", payload)) + return payload, nil +} + +func (s *TonProofService) VerifyProofAndSetAddress(ctx context.Context, userID int, tp request.TonProof) error { + op := "service.TonProofService.VerifyProofAndSetAddress" + + expectedNetwork := ton.MainnetID + if s.testnet { + expectedNetwork = ton.TestnetID + } + if tp.Network != expectedNetwork { + return fmt.Errorf("%s: network mismatch", op) + } + + proof := tonconnect.Proof{ + Address: tp.Address, + Proof: tonconnect.ProofData{ + Timestamp: tp.Proof.Timestamp, + Domain: tp.Proof.Domain.Value, + Signature: tp.Proof.Signature, + Payload: tp.Proof.Payload, + StateInit: tp.Proof.StateInit, + }, + } + + allowAnyDomain := func(addr string) (bool, error) { + return true, nil + } + + verified, _, err := s.ton.TonConnect.CheckProof(ctx, &proof, s.ton.TonConnect.CheckPayload, allowAnyDomain) + if err != nil { + slog.Error("tonproof: proof verification failed", slog.String("op", op), sl.Err(err)) + return ErrTonProofFailed + } + if !verified { + slog.Warn("tonproof: proof not verified", slog.String("op", op)) + return ErrTonProofFailed + } + + addr, err := address.ParseRawAddr(tp.Address) + if err != nil { + slog.Warn("tonproof: failed to parse raw address", sl.Err(err)) + return fmt.Errorf("%s: failed to parse address: %w", op, err) + } + + addrStr := addr.Testnet(s.testnet).String() + + _, err = s.repo.User.UpdateUser(ctx, userID, models.UpdateUserParams{ + Address: &addrStr, + }) + if err != nil { + return fmt.Errorf("%s: failed to update user: %w", op, err) + } + + return nil +} diff --git a/pkg/ton/ton.go b/pkg/ton/client.go similarity index 77% rename from pkg/ton/ton.go rename to pkg/ton/client.go index 1f32933..c220f84 100644 --- a/pkg/ton/ton.go +++ b/pkg/ton/client.go @@ -12,7 +12,6 @@ import ( "github.com/xssnick/tonutils-go/tlb" tonutils "github.com/xssnick/tonutils-go/ton" "github.com/xssnick/tonutils-go/ton/wallet" - "github.com/xssnick/tonutils-go/tvm/cell" ) const ( @@ -65,13 +64,6 @@ func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { return tc, nil } -type Wallet struct { - address *address.Address - Mnemonic []string - Instance *wallet.Wallet - testnet bool -} - func (c *Client) CreateWallet() (*Wallet, error) { return c.WalletWithSeed(strings.Join(wallet.NewSeed(), " ")) } @@ -109,35 +101,6 @@ func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint return account.State.Balance.Nano().Uint64(), nil } -func (w *Wallet) TransferTo(ctx context.Context, recipient *address.Address, amount tlb.Coins, comments ...string) (tx string, err error) { - var body *cell.Cell - - if len(comments) > 0 { - comment := strings.Join(comments, ", ") - body, err = wallet.CreateCommentCell(comment) - if err != nil { - return "", fmt.Errorf("failed to create comment cell: %w", err) - } - } - - transaction, _, err := w.Instance.SendWaitTransaction(ctx, &wallet.Message{ - Mode: wallet.PayGasSeparately + wallet.IgnoreErrors, - InternalMessage: &tlb.InternalMessage{ - IHRDisabled: true, - Bounce: false, - DstAddr: recipient, - Amount: amount, - Body: body, - }, - }) - - if err != nil { - return "", fmt.Errorf("failed to send transaction: %w", err) - } - - return fmt.Sprintf("%x", transaction.Hash), nil -} - func FromNano(nano uint64) string { return tlb.FromNanoTONU(nano).String() } @@ -183,8 +146,8 @@ func (c *Client) LookupTx(ctx context.Context, from *address.Address, to *addres return "", false } -func (w *Wallet) Address() *address.Address { - return w.address.Testnet(w.testnet) +func (c *Client) IsTestnet() bool { + return c.testnet } func (c *Client) GetAddressString(addr *address.Address) string { diff --git a/pkg/ton/wallet.go b/pkg/ton/wallet.go new file mode 100644 index 0000000..acd9097 --- /dev/null +++ b/pkg/ton/wallet.go @@ -0,0 +1,52 @@ +package ton + +import ( + "context" + "fmt" + "strings" + + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton/wallet" + "github.com/xssnick/tonutils-go/tvm/cell" +) + +type Wallet struct { + address *address.Address + Mnemonic []string + Instance *wallet.Wallet + testnet bool +} + +func (w *Wallet) TransferTo(ctx context.Context, recipient *address.Address, amount tlb.Coins, comments ...string) (tx string, err error) { + var body *cell.Cell + + if len(comments) > 0 { + comment := strings.Join(comments, ", ") + body, err = wallet.CreateCommentCell(comment) + if err != nil { + return "", fmt.Errorf("failed to create comment cell: %w", err) + } + } + + transaction, _, err := w.Instance.SendWaitTransaction(ctx, &wallet.Message{ + Mode: wallet.PayGasSeparately + wallet.IgnoreErrors, + InternalMessage: &tlb.InternalMessage{ + IHRDisabled: true, + Bounce: false, + DstAddr: recipient, + Amount: amount, + Body: body, + }, + }) + + if err != nil { + return "", fmt.Errorf("failed to send transaction: %w", err) + } + + return fmt.Sprintf("%x", transaction.Hash), nil +} + +func (w *Wallet) Address() *address.Address { + return w.address.Testnet(w.testnet) +} From 498b5c8cf2740ad186283153ef8b80982e8ca37e Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 18:02:28 +0400 Subject: [PATCH 48/56] chore: add caching balance --- internal/app/distributor/distributor.go | 2 +- internal/app/service/contest.go | 4 +- pkg/ton/address.go | 7 +++ pkg/ton/balance.go | 63 +++++++++++++++++++++++++ pkg/ton/client.go | 40 ++++++---------- 5 files changed, 88 insertions(+), 28 deletions(-) create mode 100644 pkg/ton/address.go create mode 100644 pkg/ton/balance.go diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index 70641a4..197178c 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -74,7 +74,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) - paymentID, err := r.Payment.Create(ctx, tx, wallet.Address().String(), tc.GetAddressString(recepient), amount.Nano().Uint64(), false) + paymentID, err := r.Payment.Create(ctx, tx, tc.GetAddress(wallet.Address()), tc.GetAddress(recepient), amount.Nano().Uint64(), false) if err != nil { return err } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index cbd925a..8710919 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -202,7 +202,7 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to parse wallet address: %w", op, err) } - details.PrizeNanosTON, err = s.ton.GetBalance(ctx, addr) + details.PrizeNanosTON, err = s.ton.GetBalanceCached(ctx, addr) if err != nil { // TODO: maybe on this error, just return balance = 0 (?) return nil, fmt.Errorf("%s: failed to get wallet balance: %w", op, err) @@ -382,7 +382,7 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry }, nil } - pid, err := s.repo.Payment.Create(ctx, tx, s.ton.GetAddressString(from), wallet.Address, contest.EntryPriceTonNanos, true) + pid, err := s.repo.Payment.Create(ctx, tx, s.ton.GetAddress(from), wallet.Address, contest.EntryPriceTonNanos, true) if err != nil { return EntryDetails{}, fmt.Errorf("%s: failed to create payment: %w", op, err) } diff --git a/pkg/ton/address.go b/pkg/ton/address.go new file mode 100644 index 0000000..527c87e --- /dev/null +++ b/pkg/ton/address.go @@ -0,0 +1,7 @@ +package ton + +import "github.com/xssnick/tonutils-go/address" + +func (c *Client) GetAddress(addr *address.Address) string { + return addr.Testnet(c.testnet).String() +} diff --git a/pkg/ton/balance.go b/pkg/ton/balance.go new file mode 100644 index 0000000..f784bee --- /dev/null +++ b/pkg/ton/balance.go @@ -0,0 +1,63 @@ +package ton + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/xssnick/tonutils-go/address" +) + +func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint64, error) { + block, err := c.api.CurrentMasterchainInfo(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get masterchain info: %w", err) + } + + account, err := c.api.GetAccount(ctx, block, address) + if err != nil { + return 0, fmt.Errorf("failed to get account: %w", err) + } + + if !account.IsActive { + return 0, nil + } + + return account.State.Balance.Nano().Uint64(), nil +} + +func (c *Client) GetBalanceCached(ctx context.Context, address *address.Address) (uint64, error) { + cacheKey := c.GetAddress(address) + if cached, ok := c.balanceCache.Load(cacheKey); ok { + entry := cached.(*balanceCacheEntry) + if time.Now().Before(entry.expiresAt) { + slog.Info("cache: returned balance from cache") + return entry.balance, nil + } + c.balanceCache.Delete(cacheKey) + } + + balance, err := c.GetBalance(ctx, address) + if err != nil { + return 0, err + } + + c.balanceCache.Store(cacheKey, &balanceCacheEntry{ + balance: balance, + expiresAt: time.Now().Add(balanceCacheTTL), + }) + + return balance, nil +} + +func (c *Client) ClearBalanceCache() { + c.balanceCache.Range(func(key, value interface{}) bool { + c.balanceCache.Delete(key) + return true + }) +} + +func (c *Client) InvalidateBalance(address *address.Address) { + c.balanceCache.Delete(address.String()) +} diff --git a/pkg/ton/client.go b/pkg/ton/client.go index c220f84..36911fa 100644 --- a/pkg/ton/client.go +++ b/pkg/ton/client.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "strings" + "sync" + "time" "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/config" @@ -17,12 +19,22 @@ import ( const ( MainnetID = "-239" TestnetID = "-3" + + // balanceCacheTTL is the time-to-live for cached balance entries + balanceCacheTTL = 5 * time.Minute ) +// balanceCacheEntry stores a cached balance with its expiration time +type balanceCacheEntry struct { + balance uint64 + expiresAt time.Time +} + type Client struct { - api tonutils.APIClientWrapped - testnet bool - TonConnect *tonconnect.Server + api tonutils.APIClientWrapped + testnet bool + TonConnect *tonconnect.Server + balanceCache sync.Map // map[string]*balanceCacheEntry, key is address string } func NewClient(ctx context.Context, c *config.Ton) (*Client, error) { @@ -83,24 +95,6 @@ func (c *Client) WalletWithSeed(mnemonic string) (*Wallet, error) { }, nil } -func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint64, error) { - block, err := c.api.CurrentMasterchainInfo(ctx) - if err != nil { - return 0, fmt.Errorf("failed to get masterchain info: %w", err) - } - - account, err := c.api.GetAccount(ctx, block, address) - if err != nil { - return 0, fmt.Errorf("failed to get account: %w", err) - } - - if !account.IsActive { - return 0, nil - } - - return account.State.Balance.Nano().Uint64(), nil -} - func FromNano(nano uint64) string { return tlb.FromNanoTONU(nano).String() } @@ -150,10 +144,6 @@ func (c *Client) IsTestnet() bool { return c.testnet } -func (c *Client) GetAddressString(addr *address.Address) string { - return addr.Testnet(c.testnet).String() -} - func (c *Client) API() tonutils.APIClientWrapped { return c.api } From 9e7bb60e9559ae96aba9957bd36c3eabb149a4e8 Mon Sep 17 00:00:00 2001 From: jus1d Date: Tue, 11 Nov 2025 18:04:04 +0400 Subject: [PATCH 49/56] chore: remove logging from auth mw --- internal/app/handler/account.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 8eb9640..8fe8590 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -12,7 +12,6 @@ import ( "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/jwt" - "github.com/voidcontests/api/internal/lib/logger/sl" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/validate" @@ -137,7 +136,6 @@ func (h *Handler) UserIdentity(skiperr bool) echo.MiddlewareFunc { authHeader := c.Request().Header.Get(echo.HeaderAuthorization) if authHeader == "" { - log.Debug("auth header is empty, skipping check") if skiperr { return next(c) } else { @@ -165,7 +163,6 @@ func (h *Handler) UserIdentity(skiperr bool) echo.MiddlewareFunc { }) if err != nil { - log.Debug("token parsing failed", sl.Err(err)) if skiperr { return next(c) } else { @@ -174,7 +171,6 @@ func (h *Handler) UserIdentity(skiperr bool) echo.MiddlewareFunc { } if !token.Valid { - log.Debug("invalid token") if skiperr { return next(c) } else { @@ -184,7 +180,6 @@ func (h *Handler) UserIdentity(skiperr bool) echo.MiddlewareFunc { claims, ok := token.Claims.(*jwt.CustomClaims) if !ok { - log.Debug("invalid token claims") if skiperr { return next(c) } else { From 2b846ca6f8ecbb5b88c01bc967fc24478ccca799 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 12 Nov 2025 01:35:48 +0400 Subject: [PATCH 50/56] chore: Implement mnemonic encryption --- internal/app/distributor/distributor.go | 15 +- internal/app/handler/dto/response/response.go | 2 +- internal/app/handler/handler.go | 5 +- internal/app/router/router.go | 5 +- internal/app/service/contest.go | 34 ++-- internal/app/service/service.go | 5 +- internal/config/config.go | 5 +- internal/lib/crypto/crypto.go | 86 ++++++++++ internal/lib/crypto/crypto_test.go | 150 ++++++++++++++++++ internal/pkg/app/app.go | 7 +- internal/storage/models/models.go | 8 +- .../repository/postgres/contest/contest.go | 4 +- .../repository/postgres/wallet/wallet.go | 2 +- ...0003_rename_mnemonic_to_encrypted.down.sql | 1 + ...000003_rename_mnemonic_to_encrypted.up.sql | 1 + pkg/ton/balance.go | 7 + pkg/ton/client.go | 10 -- 17 files changed, 306 insertions(+), 41 deletions(-) create mode 100644 internal/lib/crypto/crypto.go create mode 100644 internal/lib/crypto/crypto_test.go create mode 100644 migrations/000003_rename_mnemonic_to_encrypted.down.sql create mode 100644 migrations/000003_rename_mnemonic_to_encrypted.up.sql diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index 197178c..7412d7d 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -6,6 +6,7 @@ import ( "log/slog" "math/big" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ton" @@ -13,7 +14,7 @@ import ( "github.com/xssnick/tonutils-go/tlb" ) -func New(r *repository.Repository, tc *ton.Client) func(ctx context.Context) error { +func New(r *repository.Repository, tc *ton.Client, cipher crypto.Cipher) func(ctx context.Context) error { return func(ctx context.Context) error { contests, err := r.Contest.GetWithUndistributedAwards(ctx) if err != nil { @@ -21,7 +22,7 @@ func New(r *repository.Repository, tc *ton.Client) func(ctx context.Context) err } for _, c := range contests { - err := distributeAwardForContest(ctx, r, tc, c) + err := distributeAwardForContest(ctx, r, tc, cipher, c) if err != nil { return err } @@ -30,13 +31,19 @@ func New(r *repository.Repository, tc *ton.Client) func(ctx context.Context) err } } -func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc *ton.Client, c models.Contest) error { +func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc *ton.Client, cipher crypto.Cipher, c models.Contest) error { w, err := r.Contest.GetWallet(ctx, *c.WalletID) if err != nil { return err } - wallet, err := tc.WalletWithSeed(w.Mnemonic) + // Decrypt the mnemonic before using it + decryptedMnemonic, err := cipher.Decrypt(w.MnemonicEncrypted) + if err != nil { + return fmt.Errorf("failed to decrypt mnemonic: %w", err) + } + + wallet, err := tc.WalletWithSeed(decryptedMnemonic) if err != nil { return err } diff --git a/internal/app/handler/dto/response/response.go b/internal/app/handler/dto/response/response.go index f907c45..6ed3d36 100644 --- a/internal/app/handler/dto/response/response.go +++ b/internal/app/handler/dto/response/response.go @@ -72,7 +72,7 @@ type Awards struct { type Entry struct { IsAdmitted bool `json:"is_admitted"` - SubmissionDeadline time.Time `json:"submission_deadline"` + SubmissionDeadline *time.Time `json:"submission_deadline,omitempty"` Message string `json:"message,omitempty"` IsPaid bool `json:"is_paid"` Payment *PaymentDetails `json:"payment,omitempty"` diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go index 3853d5f..2870c50 100644 --- a/internal/app/handler/handler.go +++ b/internal/app/handler/handler.go @@ -8,6 +8,7 @@ import ( "github.com/voidcontests/api/internal/app/service" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/jwt" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ton" @@ -20,12 +21,12 @@ type Handler struct { service *service.Service } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Handler { +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, cipher crypto.Cipher) *Handler { return &Handler{ config: c, repo: r, broker: b, - service: service.New(&c.Security, r, b, tc), + service: service.New(&c.Security, r, b, tc, cipher), } } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index f0ae8eb..82c9d07 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -9,6 +9,7 @@ import ( "github.com/labstack/echo/v4/middleware" "github.com/voidcontests/api/internal/app/handler" "github.com/voidcontests/api/internal/config" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/lib/logger/sl" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" @@ -23,8 +24,8 @@ type Router struct { handler *handler.Handler } -func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client) *Router { - h := handler.New(c, r, b, tc) +func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Client, cipher crypto.Cipher) *Router { + h := handler.New(c, r, b, tc, cipher) return &Router{config: c, handler: h} } diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 8710919..000b835 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/internal/storage/models/award" "github.com/voidcontests/api/internal/storage/repository" @@ -17,14 +18,16 @@ import ( ) type ContestService struct { - repo *repository.Repository - ton *ton.Client + repo *repository.Repository + ton *ton.Client + cipher crypto.Cipher } -func NewContestService(repo *repository.Repository, tc *ton.Client) *ContestService { +func NewContestService(repo *repository.Repository, tc *ton.Client, cipher crypto.Cipher) *ContestService { return &ContestService{ - repo: repo, - ton: tc, + repo: repo, + ton: tc, + cipher: cipher, } } @@ -91,10 +94,16 @@ func (s *ContestService) CreateContest(ctx context.Context, params CreateContest address := w.Address().String() mnemonic := strings.Join(w.Mnemonic, " ") + // Encrypt the mnemonic before storing + encryptedMnemonic, err := s.cipher.Encrypt(mnemonic) + if err != nil { + return 0, fmt.Errorf("%s: failed to encrypt mnemonic: %w", op, err) + } + err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { repo := repository.NewTxRepository(tx) - walletID, err := repo.Wallet.Create(ctx, address, mnemonic) + walletID, err := repo.Wallet.Create(ctx, address, encryptedMnemonic) if err != nil { return fmt.Errorf("create wallet: %w", err) } @@ -195,6 +204,9 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to get wallet: %w", op, err) } + // Decrypt the mnemonic (not needed here, but keeping pattern consistent) + // The mnemonic is encrypted in the DB, but we only need the address for display + details.WalletAddress = wallet.Address addr, err := address.ParseAddr(wallet.Address) @@ -246,7 +258,7 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user if details.EntryDetails != nil { _, deadline := CalculateSubmissionWindow(contest, entry) if contest.StartTime.Before(now) { - details.EntryDetails.SubmissionDeadline = deadline + details.EntryDetails.SubmissionDeadline = &deadline } } @@ -316,7 +328,7 @@ func (s *ContestService) GetScores(ctx context.Context, contestID int, limit, of type EntryDetails struct { Entry models.Entry IsAdmitted bool - SubmissionDeadline time.Time + SubmissionDeadline *time.Time Message string Payment *models.Payment } @@ -362,7 +374,11 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry return EntryDetails{}, fmt.Errorf("%s: failed to parse user address: %w", op, err) } - wallet, err := s.repo.Contest.GetWallet(ctx, contest.ID) + if contest.WalletID == nil { + return EntryDetails{}, errors.New("prized contest has no wallet") + } + + wallet, err := s.repo.Contest.GetWallet(ctx, *contest.WalletID) if err != nil { return EntryDetails{}, fmt.Errorf("%s: failed to get wallet: %w", op, err) } diff --git a/internal/app/service/service.go b/internal/app/service/service.go index 36604f0..4fadb32 100644 --- a/internal/app/service/service.go +++ b/internal/app/service/service.go @@ -2,6 +2,7 @@ package service import ( "github.com/voidcontests/api/internal/config" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ton" @@ -15,12 +16,12 @@ type Service struct { TonProof *TonProofService } -func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client) *Service { +func New(cfg *config.Security, repo *repository.Repository, broker broker.Broker, tc *ton.Client, cipher crypto.Cipher) *Service { return &Service{ Account: NewAccountService(cfg, repo), Submission: NewSubmissionService(repo, broker), Problem: NewProblemService(repo), - Contest: NewContestService(repo, tc), + Contest: NewContestService(repo, tc, cipher), TonProof: NewTonProofService(repo, tc), } } diff --git a/internal/config/config.go b/internal/config/config.go index e4e8823..75df2ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,8 +32,9 @@ type Server struct { } type Security struct { - SignatureKey string `yaml:"signature_key" env-required:"true"` - Salt string `yaml:"salt" env-required:"true"` + SignatureKey string `yaml:"signature_key" env-required:"true"` + Salt string `yaml:"salt" env-required:"true"` + WalletEncryptKey string `yaml:"wallet_encrypt_key" env-required:"true"` } type Postgres struct { diff --git a/internal/lib/crypto/crypto.go b/internal/lib/crypto/crypto.go new file mode 100644 index 0000000..497ccf8 --- /dev/null +++ b/internal/lib/crypto/crypto.go @@ -0,0 +1,86 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io" +) + +type Cipher interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + +func NewCipher(key string) Cipher { + hash := sha256.Sum256([]byte(key)) + return &encryptor{ + key: hash[:], + } +} + +type encryptor struct { + key []byte +} + +func (e *encryptor) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", fmt.Errorf("plaintext cannot be empty") + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func (e *encryptor) Decrypt(ciphertext string) (string, error) { + if ciphertext == "" { + return "", fmt.Errorf("ciphertext cannot be empty") + } + + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("failed to decode base64: %w", err) + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("failed to create GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, encryptedData := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, encryptedData, nil) + if err != nil { + return "", fmt.Errorf("failed to decrypt: %w", err) + } + + return string(plaintext), nil +} diff --git a/internal/lib/crypto/crypto_test.go b/internal/lib/crypto/crypto_test.go new file mode 100644 index 0000000..18d638b --- /dev/null +++ b/internal/lib/crypto/crypto_test.go @@ -0,0 +1,150 @@ +package crypto + +import ( + "strings" + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + encryptor := NewCipher("test-encryption-key-12345") + + tests := []struct { + name string + plaintext string + }{ + { + name: "simple string", + plaintext: "hello world", + }, + { + name: "mnemonic phrase", + plaintext: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + }, + { + name: "long string", + plaintext: strings.Repeat("a", 1000), + }, + { + name: "special characters", + plaintext: "!@#$%^&*()_+-={}[]|\\:\";<>?,./", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ciphertext, err := encryptor.Encrypt(tt.plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + if ciphertext == "" { + t.Fatal("Encrypt() returned empty ciphertext") + } + + if ciphertext == tt.plaintext { + t.Fatal("Encrypt() returned plaintext unchanged") + } + + decrypted, err := encryptor.Decrypt(ciphertext) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + + if decrypted != tt.plaintext { + t.Errorf("Decrypt() = %v, want %v", decrypted, tt.plaintext) + } + }) + } +} + +func TestEncryptEmptyString(t *testing.T) { + encryptor := NewCipher("test-key") + + _, err := encryptor.Encrypt("") + if err == nil { + t.Error("Encrypt() should return error for empty string") + } +} + +func TestDecryptEmptyString(t *testing.T) { + encryptor := NewCipher("test-key") + + _, err := encryptor.Decrypt("") + if err == nil { + t.Error("Decrypt() should return error for empty string") + } +} + +func TestDecryptInvalidCiphertext(t *testing.T) { + encryptor := NewCipher("test-key") + + tests := []struct { + name string + ciphertext string + }{ + { + name: "invalid base64", + ciphertext: "not-valid-base64!@#", + }, + { + name: "too short", + ciphertext: "YWJj", + }, + { + name: "random data", + ciphertext: "SGVsbG8gV29ybGQ=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := encryptor.Decrypt(tt.ciphertext) + if err == nil { + t.Error("Decrypt() should return error for invalid ciphertext") + } + }) + } +} + +func TestDifferentKeys(t *testing.T) { + encryptor1 := NewCipher("key1") + encryptor2 := NewCipher("key2") + + plaintext := "secret message" + + ciphertext, err := encryptor1.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + _, err = encryptor2.Decrypt(ciphertext) + if err == nil { + t.Error("Decrypt() should fail when using different key") + } +} + +func TestEncryptionUniqueness(t *testing.T) { + encryptor := NewCipher("test-key") + plaintext := "same message" + + ciphertext1, err := encryptor.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + ciphertext2, err := encryptor.Encrypt(plaintext) + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + + if ciphertext1 == ciphertext2 { + t.Error("Encrypt() should produce different ciphertexts for same plaintext (different nonces)") + } + + decrypted1, _ := encryptor.Decrypt(ciphertext1) + decrypted2, _ := encryptor.Decrypt(ciphertext2) + + if decrypted1 != plaintext || decrypted2 != plaintext { + t.Error("Both ciphertexts should decrypt to original plaintext") + } +} diff --git a/internal/pkg/app/app.go b/internal/pkg/app/app.go index 048343c..db921d5 100644 --- a/internal/pkg/app/app.go +++ b/internal/pkg/app/app.go @@ -15,6 +15,7 @@ import ( "github.com/voidcontests/api/internal/app/distributor" "github.com/voidcontests/api/internal/app/router" "github.com/voidcontests/api/internal/config" + "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/lib/logger/prettyslog" "github.com/voidcontests/api/internal/lib/logger/sl" broker "github.com/voidcontests/api/internal/storage/broker/redis" @@ -86,7 +87,9 @@ func (a *App) Run() { slog.Info("ton: ok", slog.Bool("is_testnet", a.config.Ton.IsTestnet)) - r := router.New(a.config, repo, brok, tonc) + cipher := crypto.NewCipher(a.config.Security.WalletEncryptKey) + + r := router.New(a.config, repo, brok, tonc, cipher) server := &http.Server{ Addr: a.config.Server.Address, @@ -109,7 +112,7 @@ func (a *App) Run() { slog.Info("api: started", slog.String("address", server.Addr)) interval := 1 * time.Minute - task := distributor.New(repo, tonc) + task := distributor.New(repo, tonc, cipher) scheduler := scheduler.New(interval, task) go func() { diff --git a/internal/storage/models/models.go b/internal/storage/models/models.go index ba7f4df..a745b04 100644 --- a/internal/storage/models/models.go +++ b/internal/storage/models/models.go @@ -63,10 +63,10 @@ type ProblemCharcode struct { } type Wallet struct { - ID int `db:"id"` - Address string `db:"address"` - Mnemonic string `db:"mnemonic"` - CreatedAt time.Time `db:"created_at"` + ID int `db:"id"` + Address string `db:"address"` + MnemonicEncrypted string `db:"mnemonic_encrypted"` + CreatedAt time.Time `db:"created_at"` } type Payment struct { diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index c6b2d5c..977c6c7 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -77,10 +77,10 @@ func (p *Postgres) GetWallet(ctx context.Context, walletID int) (models.Wallet, var wallet models.Wallet query := ` SELECT - w.id, w.address, w.mnemonic, w.created_at + w.id, w.address, w.mnemonic_encrypted, w.created_at FROM wallets w WHERE w.id = $1` - err := p.conn.QueryRow(ctx, query, walletID).Scan(&wallet.ID, &wallet.Address, &wallet.Mnemonic, &wallet.CreatedAt) + err := p.conn.QueryRow(ctx, query, walletID).Scan(&wallet.ID, &wallet.Address, &wallet.MnemonicEncrypted, &wallet.CreatedAt) return wallet, err } diff --git a/internal/storage/repository/postgres/wallet/wallet.go b/internal/storage/repository/postgres/wallet/wallet.go index ce12052..5a0821c 100644 --- a/internal/storage/repository/postgres/wallet/wallet.go +++ b/internal/storage/repository/postgres/wallet/wallet.go @@ -17,7 +17,7 @@ func New(conn postgres.Transactor) *Postgres { func (p *Postgres) Create(ctx context.Context, address, mnemonic string) (int, error) { var walletID int - query := `INSERT INTO wallets (address, mnemonic) VALUES ($1, $2) RETURNING id` + query := `INSERT INTO wallets (address, mnemonic_encrypted) VALUES ($1, $2) RETURNING id` err := p.conn.QueryRow(ctx, query, address, mnemonic).Scan(&walletID) if err != nil { return 0, fmt.Errorf("insert wallet failed: %w", err) diff --git a/migrations/000003_rename_mnemonic_to_encrypted.down.sql b/migrations/000003_rename_mnemonic_to_encrypted.down.sql new file mode 100644 index 0000000..46127f5 --- /dev/null +++ b/migrations/000003_rename_mnemonic_to_encrypted.down.sql @@ -0,0 +1 @@ +ALTER TABLE wallets RENAME COLUMN mnemonic_encrypted TO mnemonic; diff --git a/migrations/000003_rename_mnemonic_to_encrypted.up.sql b/migrations/000003_rename_mnemonic_to_encrypted.up.sql new file mode 100644 index 0000000..34735d1 --- /dev/null +++ b/migrations/000003_rename_mnemonic_to_encrypted.up.sql @@ -0,0 +1 @@ +ALTER TABLE wallets RENAME COLUMN mnemonic TO mnemonic_encrypted; diff --git a/pkg/ton/balance.go b/pkg/ton/balance.go index f784bee..21db088 100644 --- a/pkg/ton/balance.go +++ b/pkg/ton/balance.go @@ -9,6 +9,13 @@ import ( "github.com/xssnick/tonutils-go/address" ) +const balanceCacheTTL = 5 * time.Minute + +type balanceCacheEntry struct { + balance uint64 + expiresAt time.Time +} + func (c *Client) GetBalance(ctx context.Context, address *address.Address) (uint64, error) { block, err := c.api.CurrentMasterchainInfo(ctx) if err != nil { diff --git a/pkg/ton/client.go b/pkg/ton/client.go index 36911fa..1d7d0e3 100644 --- a/pkg/ton/client.go +++ b/pkg/ton/client.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" "sync" - "time" "github.com/tonkeeper/tongo/tonconnect" "github.com/voidcontests/api/internal/config" @@ -19,17 +18,8 @@ import ( const ( MainnetID = "-239" TestnetID = "-3" - - // balanceCacheTTL is the time-to-live for cached balance entries - balanceCacheTTL = 5 * time.Minute ) -// balanceCacheEntry stores a cached balance with its expiration time -type balanceCacheEntry struct { - balance uint64 - expiresAt time.Time -} - type Client struct { api tonutils.APIClientWrapped testnet bool From 0a68ad68450b54ff0603ff2887ffcc8aa4f046fd Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 12 Nov 2025 01:51:27 +0400 Subject: [PATCH 51/56] test: cover ./pkg/ with tests --- go.mod | 3 + pkg/echoctx/echoctx_test.go | 112 +++++++++ pkg/ratelimit/ratelimit_test.go | 254 +++++++++++++++++++++ pkg/requestid/requestid_test.go | 126 +++++++++++ pkg/requestlog/requestlog_test.go | 253 +++++++++++++++++++++ pkg/scheduler/scheduler_test.go | 298 ++++++++++++++++++++++++ pkg/ton/ton_test.go | 244 ++++++++++++++++++++ pkg/validate/validate_test.go | 361 ++++++++++++++++++++++++++++++ 8 files changed, 1651 insertions(+) create mode 100644 pkg/echoctx/echoctx_test.go create mode 100644 pkg/ratelimit/ratelimit_test.go create mode 100644 pkg/requestid/requestid_test.go create mode 100644 pkg/requestlog/requestlog_test.go create mode 100644 pkg/scheduler/scheduler_test.go create mode 100644 pkg/ton/ton_test.go create mode 100644 pkg/validate/validate_test.go diff --git a/go.mod b/go.mod index 196b8f6..9498b74 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/labstack/echo/v4 v4.13.3 github.com/redis/go-redis/v9 v9.12.1 + github.com/stretchr/testify v1.10.0 github.com/tonkeeper/tongo v1.16.54 github.com/xssnick/tonutils-go v1.15.5 ) @@ -19,6 +20,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -28,6 +30,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/oasisprotocol/curve25519-voi v0.0.0-20220328075252-7dd334e3daae // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/snksoft/crc v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect diff --git a/pkg/echoctx/echoctx_test.go b/pkg/echoctx/echoctx_test.go new file mode 100644 index 0000000..64c7d85 --- /dev/null +++ b/pkg/echoctx/echoctx_test.go @@ -0,0 +1,112 @@ +package echoctx + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestLookup(t *testing.T) { + e := echo.New() + + t.Run("returns value and true when key exists with correct type", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + c.Set("test-string", "hello") + c.Set("test-int", 42) + c.Set("test-bool", true) + + str, ok := Lookup[string](c, "test-string") + assert.True(t, ok) + assert.Equal(t, "hello", str) + + num, ok := Lookup[int](c, "test-int") + assert.True(t, ok) + assert.Equal(t, 42, num) + + boolVal, ok := Lookup[bool](c, "test-bool") + assert.True(t, ok) + assert.True(t, boolVal) + }) + + t.Run("returns zero value and false when key does not exist", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + str, ok := Lookup[string](c, "nonexistent") + assert.False(t, ok) + assert.Equal(t, "", str) + + num, ok := Lookup[int](c, "nonexistent") + assert.False(t, ok) + assert.Equal(t, 0, num) + }) + + t.Run("returns zero value and false when type mismatch", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + c.Set("test-string", "hello") + + // Try to get string value as int + num, ok := Lookup[int](c, "test-string") + assert.False(t, ok) + assert.Equal(t, 0, num) + + // Try to get string value as bool + boolVal, ok := Lookup[bool](c, "test-string") + assert.False(t, ok) + assert.False(t, boolVal) + }) + + t.Run("works with custom types", func(t *testing.T) { + type CustomStruct struct { + Name string + Age int + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + expected := CustomStruct{Name: "John", Age: 30} + c.Set("custom", expected) + + result, ok := Lookup[CustomStruct](c, "custom") + assert.True(t, ok) + assert.Equal(t, expected, result) + }) + + t.Run("works with pointer types", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + str := "hello" + c.Set("ptr", &str) + + ptr, ok := Lookup[*string](c, "ptr") + assert.True(t, ok) + assert.NotNil(t, ptr) + assert.Equal(t, "hello", *ptr) + }) + + t.Run("returns false when value is nil", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + c.Set("nil-value", nil) + + str, ok := Lookup[string](c, "nil-value") + assert.False(t, ok) + assert.Equal(t, "", str) + }) +} diff --git a/pkg/ratelimit/ratelimit_test.go b/pkg/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..9977779 --- /dev/null +++ b/pkg/ratelimit/ratelimit_test.go @@ -0,0 +1,254 @@ +package ratelimit + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestWithTimeout(t *testing.T) { + e := echo.New() + + t.Run("allows first request from IP", func(t *testing.T) { + middleware := WithTimeout(1 * time.Second) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := handler(c) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("blocks second request from same IP within timeout", func(t *testing.T) { + middleware := WithTimeout(500 * time.Millisecond) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", "192.168.1.2") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + + err := handler(c1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "192.168.1.2") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + + err = handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusTooManyRequests, rec2.Code) + }) + + t.Run("allows request after timeout expires", func(t *testing.T) { + duration := 100 * time.Millisecond + middleware := WithTimeout(duration) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", "192.168.1.3") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + + err := handler(c1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec1.Code) + + time.Sleep(duration + 10*time.Millisecond) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "192.168.1.3") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + + err = handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec2.Code) + }) + + t.Run("tracks different IPs independently", func(t *testing.T) { + middleware := WithTimeout(1 * time.Second) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", "192.168.1.4") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + + err := handler(c1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "192.168.1.5") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + + err = handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec2.Code) + }) + + t.Run("returns JSON error response with timeout information", func(t *testing.T) { + duration := 2 * time.Second + middleware := WithTimeout(duration) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", "192.168.1.6") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + + _ = handler(c1) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "192.168.1.6") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + + err := handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusTooManyRequests, rec2.Code) + assert.Contains(t, rec2.Body.String(), "rate limit exceeded") + assert.Contains(t, rec2.Body.String(), "timeout") + }) + + t.Run("uses RealIP from context", func(t *testing.T) { + middleware := WithTimeout(500 * time.Millisecond) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Forwarded-For", "10.0.0.1") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + + err := handler(c1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Forwarded-For", "10.0.0.1") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + + err = handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusTooManyRequests, rec2.Code) + }) + + t.Run("allows multiple requests within sequence correctly", func(t *testing.T) { + duration := 100 * time.Millisecond + middleware := WithTimeout(duration) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + ip := "192.168.1.7" + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", ip) + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + err := handler(c1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec1.Code) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", ip) + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + err = handler(c2) + assert.NoError(t, err) + assert.Equal(t, http.StatusTooManyRequests, rec2.Code) + + time.Sleep(duration + 10*time.Millisecond) + + req3 := httptest.NewRequest(http.MethodGet, "/", nil) + req3.Header.Set("X-Real-IP", ip) + rec3 := httptest.NewRecorder() + c3 := e.NewContext(req3, rec3) + err = handler(c3) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec3.Code) + }) + + t.Run("handles concurrent requests from different IPs", func(t *testing.T) { + middleware := WithTimeout(1 * time.Second) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + successCount := 0 + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func(index int) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Real-IP", "192.168.1."+string(rune(100+index))) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + _ = handler(c) + if rec.Code == http.StatusOK { + done <- true + } else { + done <- false + } + }(i) + } + + for i := 0; i < 10; i++ { + if <-done { + successCount++ + } + } + + assert.Equal(t, 10, successCount) + }) + + t.Run("timeout value in response is accurate", func(t *testing.T) { + duration := 5 * time.Second + middleware := WithTimeout(duration) + handler := middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + req1.Header.Set("X-Real-IP", "192.168.1.8") + rec1 := httptest.NewRecorder() + c1 := e.NewContext(req1, rec1) + _ = handler(c1) + + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.Header.Set("X-Real-IP", "192.168.1.8") + rec2 := httptest.NewRecorder() + c2 := e.NewContext(req2, rec2) + _ = handler(c2) + + body := rec2.Body.String() + assert.Contains(t, body, "timeout") + assert.Contains(t, body, "5s") + }) +} diff --git a/pkg/requestid/requestid_test.go b/pkg/requestid/requestid_test.go new file mode 100644 index 0000000..77b7b44 --- /dev/null +++ b/pkg/requestid/requestid_test.go @@ -0,0 +1,126 @@ +package requestid + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + e := echo.New() + + t.Run("generates and sets request ID when not present", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + called := false + handler := New(func(c echo.Context) error { + called = true + rid := c.Request().Header.Get(headerRequestID) + assert.NotEmpty(t, rid) + _, err := uuid.Parse(rid) + assert.NoError(t, err) + return nil + }) + + err := handler(c) + assert.NoError(t, err) + assert.True(t, called) + }) + + t.Run("preserves existing request ID from response header", func(t *testing.T) { + existingID := "existing-request-id" + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + rec.Header().Set(headerRequestID, existingID) + c := e.NewContext(req, rec) + + called := false + handler := New(func(c echo.Context) error { + called = true + return nil + }) + + err := handler(c) + assert.NoError(t, err) + assert.True(t, called) + }) + + t.Run("calls next handler", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + nextCalled := false + handler := New(func(c echo.Context) error { + nextCalled = true + return nil + }) + + err := handler(c) + assert.NoError(t, err) + assert.True(t, nextCalled) + }) + + t.Run("propagates error from next handler", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + expectedErr := echo.NewHTTPError(http.StatusInternalServerError, "test error") + handler := New(func(c echo.Context) error { + return expectedErr + }) + + err := handler(c) + assert.Equal(t, expectedErr, err) + }) +} + +func TestGet(t *testing.T) { + e := echo.New() + + t.Run("returns request ID from header", func(t *testing.T) { + expectedID := "test-request-id-123" + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(headerRequestID, expectedID) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + rid := Get(c) + assert.Equal(t, expectedID, rid) + }) + + t.Run("returns empty string when request ID not set", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + rid := Get(c) + assert.Equal(t, "", rid) + }) + + t.Run("integration: Get returns ID set by New middleware", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var capturedID string + handler := New(func(c echo.Context) error { + capturedID = Get(c) + return nil + }) + + err := handler(c) + assert.NoError(t, err) + assert.NotEmpty(t, capturedID) + + _, err = uuid.Parse(capturedID) + assert.NoError(t, err) + }) +} diff --git a/pkg/requestlog/requestlog_test.go b/pkg/requestlog/requestlog_test.go new file mode 100644 index 0000000..34726e4 --- /dev/null +++ b/pkg/requestlog/requestlog_test.go @@ -0,0 +1,253 @@ +package requestlog + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/voidcontests/api/internal/app/handler" +) + +func TestCompleted(t *testing.T) { + e := echo.New() + + t.Run("calls next handler and logs request", func(t *testing.T) { + nextCalled := false + middleware := Completed(func(c echo.Context) error { + nextCalled = true + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "test-request-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + assert.True(t, nextCalled) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("skips OPTIONS requests", func(t *testing.T) { + nextCalled := false + middleware := Completed(func(c echo.Context) error { + nextCalled = true + return c.NoContent(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodOptions, "/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + assert.True(t, nextCalled) + }) + + t.Run("skips logging for healthcheck endpoint with 200 status", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "healthy") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/healthcheck", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/healthcheck") + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("logs healthcheck endpoint with non-200 status", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusInternalServerError, "unhealthy") + }) + + req := httptest.NewRequest(http.MethodGet, "/api/healthcheck", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/healthcheck") + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("propagates error from next handler", func(t *testing.T) { + expectedErr := errors.New("test error") + middleware := Completed(func(c echo.Context) error { + return expectedErr + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.Equal(t, expectedErr, err) + }) + + t.Run("handles APIError and extracts status code", func(t *testing.T) { + apiErr := &handler.APIError{ + Status: http.StatusBadRequest, + Message: "bad request", + } + + middleware := Completed(func(c echo.Context) error { + return apiErr + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.Equal(t, apiErr, err) + }) + + t.Run("handles non-APIError and uses status 500", func(t *testing.T) { + genericErr := errors.New("generic error") + + middleware := Completed(func(c echo.Context) error { + return genericErr + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.Equal(t, genericErr, err) + }) + + t.Run("logs correct request information", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusCreated, "created") + }) + + req := httptest.NewRequest(http.MethodPost, "/api/users", nil) + req.Header.Set("X-Request-ID", "req-123") + req.Header.Set("User-Agent", "TestAgent/1.0") + req.Host = "example.com" + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/users") + + err := middleware(c) + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + }) + + t.Run("measures request duration", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("handles different HTTP methods", func(t *testing.T) { + methods := []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + } + + for _, method := range methods { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(method, "/test", nil) + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + } + }) + + t.Run("retrieves request ID from context", func(t *testing.T) { + expectedID := "custom-request-id-456" + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", expectedID) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("logs RealIP from context", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Real-IP", "203.0.113.42") + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("handles empty user agent", func(t *testing.T) { + middleware := Completed(func(c echo.Context) error { + return c.String(http.StatusOK, "success") + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "test-id") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := middleware(c) + assert.NoError(t, err) + }) + + t.Run("integration: full request lifecycle", func(t *testing.T) { + handlerCalled := false + middleware := Completed(func(c echo.Context) error { + handlerCalled = true + return c.JSON(http.StatusOK, map[string]string{ + "message": "success", + }) + }) + + req := httptest.NewRequest(http.MethodPost, "/api/data", nil) + req.Header.Set("X-Request-ID", "integration-test-id") + req.Header.Set("User-Agent", "IntegrationTest/1.0") + req.Header.Set("X-Real-IP", "192.168.1.100") + req.Host = "api.example.com" + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/api/data") + + err := middleware(c) + assert.NoError(t, err) + assert.True(t, handlerCalled) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "success") + }) +} diff --git a/pkg/scheduler/scheduler_test.go b/pkg/scheduler/scheduler_test.go new file mode 100644 index 0000000..1fa9ef2 --- /dev/null +++ b/pkg/scheduler/scheduler_test.go @@ -0,0 +1,298 @@ +package scheduler + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + t.Run("creates scheduler with correct interval and task", func(t *testing.T) { + interval := 100 * time.Millisecond + task := func(ctx context.Context) error { + return nil + } + + s := New(interval, task) + + assert.NotNil(t, s) + assert.Equal(t, interval, s.interval) + assert.NotNil(t, s.task) + assert.NotNil(t, s.stop) + assert.NotNil(t, s.done) + }) +} + +func TestScheduler_Start(t *testing.T) { + t.Run("executes task immediately on start", func(t *testing.T) { + executed := false + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + executed = true + mu.Unlock() + return nil + } + + s := New(1*time.Hour, task) + ctx := context.Background() + + go s.Start(ctx) + time.Sleep(50 * time.Millisecond) + s.Stop() + + mu.Lock() + assert.True(t, executed) + mu.Unlock() + }) + + t.Run("executes task periodically", func(t *testing.T) { + var count int + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + count++ + mu.Unlock() + return nil + } + + interval := 50 * time.Millisecond + s := New(interval, task) + ctx := context.Background() + + go s.Start(ctx) + time.Sleep(160 * time.Millisecond) + s.Stop() + + mu.Lock() + assert.GreaterOrEqual(t, count, 3) + mu.Unlock() + }) + + t.Run("stops when Stop is called", func(t *testing.T) { + var count int + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + count++ + mu.Unlock() + return nil + } + + interval := 30 * time.Millisecond + s := New(interval, task) + ctx := context.Background() + + go s.Start(ctx) + time.Sleep(100 * time.Millisecond) + s.Stop() + + mu.Lock() + countAfterStop := count + mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + assert.Equal(t, countAfterStop, count, "task should not execute after Stop") + mu.Unlock() + }) + + t.Run("stops when context is cancelled", func(t *testing.T) { + var count int + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + count++ + mu.Unlock() + return nil + } + + interval := 30 * time.Millisecond + s := New(interval, task) + ctx, cancel := context.WithCancel(context.Background()) + + go s.Start(ctx) + time.Sleep(100 * time.Millisecond) + cancel() + + time.Sleep(50 * time.Millisecond) + + mu.Lock() + countAfterCancel := count + mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + assert.Equal(t, countAfterCancel, count, "task should not execute after context cancellation") + mu.Unlock() + }) + + t.Run("continues execution when task returns error", func(t *testing.T) { + var count int + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + count++ + mu.Unlock() + return errors.New("test error") + } + + interval := 50 * time.Millisecond + s := New(interval, task) + ctx := context.Background() + + go s.Start(ctx) + time.Sleep(160 * time.Millisecond) + s.Stop() + + mu.Lock() + assert.GreaterOrEqual(t, count, 3, "scheduler should continue despite task errors") + mu.Unlock() + }) + + t.Run("task receives correct context", func(t *testing.T) { + type contextKey string + key := contextKey("test-key") + expectedValue := "test-value" + + var receivedValue string + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + if val := ctx.Value(key); val != nil { + receivedValue = val.(string) + } + mu.Unlock() + return nil + } + + interval := 1 * time.Hour + s := New(interval, task) + ctx := context.WithValue(context.Background(), key, expectedValue) + + go s.Start(ctx) + time.Sleep(50 * time.Millisecond) + s.Stop() + + mu.Lock() + assert.Equal(t, expectedValue, receivedValue) + mu.Unlock() + }) +} + +func TestScheduler_Stop(t *testing.T) { + t.Run("waits for scheduler to finish", func(t *testing.T) { + taskStarted := make(chan struct{}) + taskCanFinish := make(chan struct{}) + + task := func(ctx context.Context) error { + close(taskStarted) + <-taskCanFinish + return nil + } + + s := New(1*time.Hour, task) + ctx := context.Background() + + go s.Start(ctx) + + <-taskStarted + + stopFinished := make(chan struct{}) + go func() { + s.Stop() + close(stopFinished) + }() + + select { + case <-stopFinished: + t.Fatal("Stop should wait for task to finish") + case <-time.After(50 * time.Millisecond): + } + + close(taskCanFinish) + + select { + case <-stopFinished: + case <-time.After(100 * time.Millisecond): + t.Fatal("Stop did not complete in time") + } + }) + + t.Run("Stop waits for graceful shutdown", func(t *testing.T) { + task := func(ctx context.Context) error { + return nil + } + + s := New(1*time.Hour, task) + ctx := context.Background() + + go s.Start(ctx) + time.Sleep(50 * time.Millisecond) + + done := make(chan struct{}) + go func() { + s.Stop() + close(done) + }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("Stop deadlocked or hung") + } + }) +} + +func TestScheduler_Integration(t *testing.T) { + t.Run("realistic usage scenario", func(t *testing.T) { + var executionTimes []time.Time + var mu sync.Mutex + + task := func(ctx context.Context) error { + mu.Lock() + executionTimes = append(executionTimes, time.Now()) + mu.Unlock() + return nil + } + + interval := 40 * time.Millisecond + s := New(interval, task) + ctx := context.Background() + + start := time.Now() + go s.Start(ctx) + time.Sleep(150 * time.Millisecond) + s.Stop() + elapsed := time.Since(start) + + mu.Lock() + count := len(executionTimes) + mu.Unlock() + + assert.GreaterOrEqual(t, count, 3) + assert.Less(t, elapsed, 200*time.Millisecond) + + mu.Lock() + if len(executionTimes) >= 2 { + for i := 1; i < len(executionTimes); i++ { + gap := executionTimes[i].Sub(executionTimes[i-1]) + assert.Greater(t, gap, 30*time.Millisecond) + assert.Less(t, gap, 60*time.Millisecond) + } + } + mu.Unlock() + }) +} diff --git a/pkg/ton/ton_test.go b/pkg/ton/ton_test.go new file mode 100644 index 0000000..9b7a79a --- /dev/null +++ b/pkg/ton/ton_test.go @@ -0,0 +1,244 @@ +package ton + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/xssnick/tonutils-go/address" +) + +func TestFromNano(t *testing.T) { + tests := []struct { + name string + nano uint64 + expected string + }{ + { + name: "zero nanotons", + nano: 0, + expected: "0", + }, + { + name: "one TON", + nano: 1_000_000_000, + expected: "1", + }, + { + name: "fractional TON", + nano: 500_000_000, + expected: "0.5", + }, + { + name: "small amount", + nano: 1, + expected: "0.000000001", + }, + { + name: "large amount", + nano: 1_234_567_890_000, + expected: "1234.56789", + }, + { + name: "10.5 TON", + nano: 10_500_000_000, + expected: "10.5", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FromNano(tt.nano) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestClient_IsTestnet(t *testing.T) { + t.Run("returns true for testnet client", func(t *testing.T) { + client := &Client{testnet: true} + assert.True(t, client.IsTestnet()) + }) + + t.Run("returns false for mainnet client", func(t *testing.T) { + client := &Client{testnet: false} + assert.False(t, client.IsTestnet()) + }) +} + +func TestClient_GetAddress(t *testing.T) { + t.Run("formats address for testnet", func(t *testing.T) { + addr, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + client := &Client{testnet: true} + + result := client.GetAddress(addr) + assert.NotEmpty(t, result) + assert.True(t, result[0] == 'k' || result[0] == '0') + }) + + t.Run("formats address for mainnet", func(t *testing.T) { + addr, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + client := &Client{testnet: false} + + result := client.GetAddress(addr) + assert.NotEmpty(t, result) + assert.True(t, result[0] == 'E' || result[0] == '0') + }) +} + +func TestWallet_Address(t *testing.T) { + t.Run("returns testnet address when testnet is true", func(t *testing.T) { + addr, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + wallet := &Wallet{ + address: addr, + testnet: true, + } + + result := wallet.Address() + assert.NotNil(t, result) + }) + + t.Run("returns mainnet address when testnet is false", func(t *testing.T) { + addr, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + wallet := &Wallet{ + address: addr, + testnet: false, + } + + result := wallet.Address() + assert.NotNil(t, result) + }) +} + +func TestClient_ClearBalanceCache(t *testing.T) { + t.Run("clears all cached balances", func(t *testing.T) { + client := &Client{} + + addr1, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + addr2, err := address.ParseRawAddr("0:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + assert.NoError(t, err) + + key1 := client.GetAddress(addr1) + key2 := client.GetAddress(addr2) + + client.balanceCache.Store(key1, &balanceCacheEntry{balance: 1000}) + client.balanceCache.Store(key2, &balanceCacheEntry{balance: 2000}) + + _, ok1 := client.balanceCache.Load(key1) + _, ok2 := client.balanceCache.Load(key2) + assert.True(t, ok1) + assert.True(t, ok2) + + client.ClearBalanceCache() + + _, ok1 = client.balanceCache.Load(key1) + _, ok2 = client.balanceCache.Load(key2) + assert.False(t, ok1) + assert.False(t, ok2) + }) + + t.Run("works with empty cache", func(t *testing.T) { + client := &Client{} + + assert.NotPanics(t, func() { + client.ClearBalanceCache() + }) + }) +} + +func TestClient_InvalidateBalance(t *testing.T) { + t.Run("invalidates specific address balance", func(t *testing.T) { + client := &Client{} + + addr1, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + addr2, err := address.ParseRawAddr("0:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") + assert.NoError(t, err) + + key1 := addr1.String() + key2 := addr2.String() + + client.balanceCache.Store(key1, &balanceCacheEntry{balance: 1000}) + client.balanceCache.Store(key2, &balanceCacheEntry{balance: 2000}) + + client.InvalidateBalance(addr1) + + _, ok1 := client.balanceCache.Load(key1) + _, ok2 := client.balanceCache.Load(key2) + assert.False(t, ok1) + assert.True(t, ok2) + }) + + t.Run("works when address not in cache", func(t *testing.T) { + client := &Client{} + addr, err := address.ParseRawAddr("0:8156fbabb2c8c8119dab794ca096f57c3af1775549469f0d1b4e766d8e613c36") + assert.NoError(t, err) + + assert.NotPanics(t, func() { + client.InvalidateBalance(addr) + }) + }) +} + +func TestConstants(t *testing.T) { + t.Run("mainnet ID is correct", func(t *testing.T) { + assert.Equal(t, "-239", MainnetID) + }) + + t.Run("testnet ID is correct", func(t *testing.T) { + assert.Equal(t, "-3", TestnetID) + }) +} + +/* +Integration Tests (require actual blockchain connection): + +The following tests would require a connection to TON blockchain (testnet or mainnet) +and should be run as integration tests with proper setup: + +1. TestNewClient - requires valid config and network connection +2. TestClient_CreateWallet - requires API client +3. TestClient_WalletWithSeed - requires API client and valid mnemonic +4. TestClient_GetBalance - requires API client and blockchain query +5. TestClient_GetBalanceCached - requires API client, tests caching behavior +6. TestClient_LookupTx - requires API client and actual transactions +7. TestWallet_TransferTo - requires API client, funded wallet, and actual transfer +8. TestClient_RunSmcMethodByID - requires API client and smart contract + +Example integration test structure: + +func TestIntegration_CreateWallet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + cfg := &config.Ton{ + ConfigURL: "https: IsTestnet: true, + Proof: config.TonProof{ + PayloadLifetime: time.Minute * 5, + ProofLifetime: time.Minute * 5, + PayloadSignatureKey: "test-key", + }, + } + + client, err := NewClient(ctx, cfg) + require.NoError(t, err) + require.NotNil(t, client) + + wallet, err := client.CreateWallet() + require.NoError(t, err) + require.NotNil(t, wallet) + require.NotNil(t, wallet.Address()) + require.Len(t, wallet.Mnemonic, 24) +} + +To run integration tests: + go test -v ./pkg/ton/... -run Integration +To skip integration tests: + go test -v ./pkg/ton/... -short +*/ diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go new file mode 100644 index 0000000..5ba6d59 --- /dev/null +++ b/pkg/validate/validate_test.go @@ -0,0 +1,361 @@ +package validate + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestBind(t *testing.T) { + e := echo.New() + + t.Run("successfully binds and validates valid JSON", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + Email string `json:"email" required:"true"` + Age int `json:"age"` + } + + jsonBody := `{"name":"John","email":"john@example.com","age":30}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + err := Bind(c, &result) + + assert.NoError(t, err) + assert.Equal(t, "John", result.Name) + assert.Equal(t, "john@example.com", result.Email) + assert.Equal(t, 30, result.Age) + }) + + t.Run("returns error when required field is missing", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + Email string `json:"email" required:"true"` + } + + jsonBody := `{"name":"John"}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + err := Bind(c, &result) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "email") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when destination is not a pointer", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + + jsonBody := `{"name":"John"}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + err := Bind(c, result) + assert.Error(t, err) + assert.Contains(t, err.Error(), "pointer") + }) + + t.Run("returns error on invalid JSON", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + + jsonBody := `{"name":invalid}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + err := Bind(c, &result) + + assert.Error(t, err) + }) + + t.Run("sets Content-Type header to application/json", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + + jsonBody := `{"name":"John"}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonBody)) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + _ = Bind(c, &result) + + assert.Equal(t, "application/json", c.Request().Header.Get("Content-Type")) + }) +} + +func TestStruct(t *testing.T) { + t.Run("validates struct with all required fields present", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + Email string `json:"email" required:"true"` + Age int `json:"age" required:"true"` + } + + data := &TestStruct{ + Name: "John", + Email: "john@example.com", + Age: 30, + } + + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when string field is empty", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + } + + data := &TestStruct{Name: ""} + err := Struct(data) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "name") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when int field is zero or negative", func(t *testing.T) { + type TestStruct struct { + Age int `json:"age" required:"true"` + } + + data := &TestStruct{Age: 0} + err := Struct(data) + assert.Error(t, err) + + data = &TestStruct{Age: -1} + err = Struct(data) + assert.Error(t, err) + }) + + t.Run("validates positive int field", func(t *testing.T) { + type TestStruct struct { + Age int `json:"age" required:"true"` + } + + data := &TestStruct{Age: 30} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when slice is empty", func(t *testing.T) { + type TestStruct struct { + Tags []string `json:"tags" required:"true"` + } + + data := &TestStruct{Tags: []string{}} + err := Struct(data) + assert.Error(t, err) + }) + + t.Run("validates non-empty slice", func(t *testing.T) { + type TestStruct struct { + Tags []string `json:"tags" required:"true"` + } + + data := &TestStruct{Tags: []string{"tag1", "tag2"}} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("validates nested structs", func(t *testing.T) { + type Address struct { + Street string `json:"street" required:"true"` + City string `json:"city" required:"true"` + } + + type Person struct { + Name string `json:"name" required:"true"` + Address Address `json:"address"` + } + + data := &Person{ + Name: "John", + Address: Address{ + Street: "123 Main St", + City: "New York", + }, + } + + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when nested struct has missing required field", func(t *testing.T) { + type Address struct { + Street string `json:"street" required:"true"` + City string `json:"city" required:"true"` + } + + type Person struct { + Name string `json:"name" required:"true"` + Address Address `json:"address"` + } + + data := &Person{ + Name: "John", + Address: Address{ + Street: "123 Main St", + City: ""}, + } + + err := Struct(data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Address") + assert.Contains(t, err.Error(), "city") + }) + + t.Run("handles time.Time fields without validating them as structs", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + CreatedAt time.Time `json:"created_at"` + } + + data := &TestStruct{ + Name: "John", + CreatedAt: time.Now(), + } + + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when destination is not a pointer", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name"` + } + + data := TestStruct{Name: "John"} + err := Struct(data) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "pointer") + }) + + t.Run("returns error when destination is not a struct", func(t *testing.T) { + data := "not a struct" + err := Struct(&data) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "struct") + }) + + t.Run("validates pointer fields", func(t *testing.T) { + type TestStruct struct { + Name *string `json:"name" required:"true"` + } + + name := "John" + data := &TestStruct{Name: &name} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when pointer field is nil", func(t *testing.T) { + type TestStruct struct { + Name *string `json:"name" required:"true"` + } + + data := &TestStruct{Name: nil} + err := Struct(data) + assert.Error(t, err) + }) + + t.Run("validates map fields", func(t *testing.T) { + type TestStruct struct { + Metadata map[string]string `json:"metadata" required:"true"` + } + + data := &TestStruct{Metadata: map[string]string{"key": "value"}} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when map field is empty", func(t *testing.T) { + type TestStruct struct { + Metadata map[string]string `json:"metadata" required:"true"` + } + + data := &TestStruct{Metadata: map[string]string{}} + err := Struct(data) + assert.Error(t, err) + }) + + t.Run("validates float fields", func(t *testing.T) { + type TestStruct struct { + Price float64 `json:"price" required:"true"` + } + + data := &TestStruct{Price: 9.99} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when float field is zero or negative", func(t *testing.T) { + type TestStruct struct { + Price float64 `json:"price" required:"true"` + } + + data := &TestStruct{Price: 0.0} + err := Struct(data) + assert.Error(t, err) + + data = &TestStruct{Price: -1.5} + err = Struct(data) + assert.Error(t, err) + }) + + t.Run("validates uint fields", func(t *testing.T) { + type TestStruct struct { + Count uint `json:"count" required:"true"` + } + + data := &TestStruct{Count: 10} + err := Struct(data) + assert.NoError(t, err) + }) + + t.Run("returns error when uint field is zero", func(t *testing.T) { + type TestStruct struct { + Count uint `json:"count" required:"true"` + } + + data := &TestStruct{Count: 0} + err := Struct(data) + assert.Error(t, err) + }) + + t.Run("ignores fields without required tag", func(t *testing.T) { + type TestStruct struct { + Name string `json:"name" required:"true"` + Optional string `json:"optional"` + } + + data := &TestStruct{ + Name: "John", + Optional: ""} + + err := Struct(data) + assert.NoError(t, err) + }) +} From f5e231d6f3ff954402cf3f97285d560ba83ec620 Mon Sep 17 00:00:00 2001 From: jus1d Date: Wed, 12 Nov 2025 18:25:27 +0400 Subject: [PATCH 52/56] migrations: sucked into one master migration --- migrations/000001_tables.up.sql | 3 +-- migrations/000003_rename_mnemonic_to_encrypted.down.sql | 1 - migrations/000003_rename_mnemonic_to_encrypted.up.sql | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 migrations/000003_rename_mnemonic_to_encrypted.down.sql delete mode 100644 migrations/000003_rename_mnemonic_to_encrypted.up.sql diff --git a/migrations/000001_tables.up.sql b/migrations/000001_tables.up.sql index 57fb821..d73c099 100644 --- a/migrations/000001_tables.up.sql +++ b/migrations/000001_tables.up.sql @@ -25,11 +25,10 @@ CREATE TABLE users ( CREATE INDEX idx_users_role_id ON users(role_id); CREATE UNIQUE INDEX unique_user_address ON users(address) WHERE address <> ''; --- TODO: encrypt the mnemonic before saving CREATE TABLE wallets ( id SERIAL PRIMARY KEY, address VARCHAR(48) UNIQUE NOT NULL, - mnemonic TEXT NOT NULL, + mnemonic_encrypted TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() NOT NULL ); diff --git a/migrations/000003_rename_mnemonic_to_encrypted.down.sql b/migrations/000003_rename_mnemonic_to_encrypted.down.sql deleted file mode 100644 index 46127f5..0000000 --- a/migrations/000003_rename_mnemonic_to_encrypted.down.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE wallets RENAME COLUMN mnemonic_encrypted TO mnemonic; diff --git a/migrations/000003_rename_mnemonic_to_encrypted.up.sql b/migrations/000003_rename_mnemonic_to_encrypted.up.sql deleted file mode 100644 index 34735d1..0000000 --- a/migrations/000003_rename_mnemonic_to_encrypted.up.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE wallets RENAME COLUMN mnemonic TO mnemonic_encrypted; From c7b277e8a6db2105d4455386980b13389ba7734c Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 15 Nov 2025 00:04:24 +0400 Subject: [PATCH 53/56] chore: Add request_id to every error response --- internal/app/handler/error.go | 36 +++++++++++++++++++++++++++++++++ internal/app/router/router.go | 24 +--------------------- internal/app/service/contest.go | 4 ++++ internal/app/service/errors.go | 1 + 4 files changed, 42 insertions(+), 23 deletions(-) create mode 100644 internal/app/handler/error.go diff --git a/internal/app/handler/error.go b/internal/app/handler/error.go new file mode 100644 index 0000000..ff97c56 --- /dev/null +++ b/internal/app/handler/error.go @@ -0,0 +1,36 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/voidcontests/api/internal/lib/logger/sl" + "github.com/voidcontests/api/pkg/requestid" +) + +func ErorHTTP(err error, c echo.Context) { + requestID := requestid.Get(c) + if he, ok := err.(*echo.HTTPError); ok && (he.Code == http.StatusNotFound || he.Code == http.StatusMethodNotAllowed) { + c.JSON(http.StatusNotFound, map[string]string{ + "message": "resource not found", + "request_id": requestID, + }) + return + } + + if ae, ok := err.(*APIError); ok { + slog.Debug("responded with API error", sl.Err(err), slog.String("request_id", requestid.Get(c))) + c.JSON(ae.Status, map[string]any{ + "message": ae.Message, + "request_id": requestID, + }) + return + } + + slog.Error("something went wrong", sl.Err(err), slog.String("request_id", requestid.Get(c))) + c.JSON(http.StatusInternalServerError, map[string]any{ + "message": "internal server error", + "request_id": requestID, + }) +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 82c9d07..6bafa5f 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -1,7 +1,6 @@ package router import ( - "log/slog" "net/http" "time" @@ -10,7 +9,6 @@ import ( "github.com/voidcontests/api/internal/app/handler" "github.com/voidcontests/api/internal/config" "github.com/voidcontests/api/internal/lib/crypto" - "github.com/voidcontests/api/internal/lib/logger/sl" "github.com/voidcontests/api/internal/storage/broker" "github.com/voidcontests/api/internal/storage/repository" "github.com/voidcontests/api/pkg/ratelimit" @@ -32,27 +30,7 @@ func New(c *config.Config, r *repository.Repository, b broker.Broker, tc *ton.Cl func (r *Router) InitRoutes() *echo.Echo { router := echo.New() - router.HTTPErrorHandler = func(err error, c echo.Context) { - if he, ok := err.(*echo.HTTPError); ok && (he.Code == http.StatusNotFound || he.Code == http.StatusMethodNotAllowed) { - c.JSON(http.StatusNotFound, map[string]string{ - "message": "resource not found", - }) - return - } - - if ae, ok := err.(*handler.APIError); ok { - slog.Debug("responded with API error", sl.Err(err), slog.String("request_id", requestid.Get(c))) - c.JSON(ae.Status, map[string]any{ - "message": ae.Message, - }) - return - } - - slog.Error("something went wrong", sl.Err(err), slog.String("request_id", requestid.Get(c))) - c.JSON(http.StatusInternalServerError, map[string]any{ - "message": "internal server error", - }) - } + router.HTTPErrorHandler = handler.ErorHTTP router.Use(requestid.New) router.Use(requestlog.Completed) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 000b835..48d12ba 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -433,6 +433,10 @@ func (s *ContestService) CreateEntry(ctx context.Context, contestID int, userID return fmt.Errorf("%s: failed to get contest: %w", op, err) } + if contest.CreatorID == userID { + return ErrCannotJoinOwnContest + } + entriesCount, err := s.repo.Contest.GetEntriesCount(ctx, contestID) if err != nil { return fmt.Errorf("%s: failed to get entries count: %w", op, err) diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index 02397de..9438788 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -16,6 +16,7 @@ var ( // entry ErrContestFinished = errors.New("contest not found") ErrContestNotFound = errors.New("contest not found") + ErrCannotJoinOwnContest = errors.New("cannot join your own contest") ErrMaxSlotsReached = errors.New("max slots limit reached") ErrApplicationTimeOver = errors.New("application time is over") ErrEntryAlreadyExists = errors.New("user already has entry for this contest") From 2a0722c367dbe88e54f99f3257dbbeda86372ff1 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sat, 15 Nov 2025 19:21:19 +0400 Subject: [PATCH 54/56] fix: correctly return is_admitted after payment --- internal/app/service/contest.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 48d12ba..46a9f8b 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -204,9 +204,6 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user return nil, fmt.Errorf("%s: failed to get wallet: %w", op, err) } - // Decrypt the mnemonic (not needed here, but keeping pattern consistent) - // The mnemonic is encrypted in the DB, but we only need the address for display - details.WalletAddress = wallet.Address addr, err := address.ParseAddr(wallet.Address) @@ -416,8 +413,7 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry return EntryDetails{ Entry: entry, - IsAdmitted: false, - Message: "payment required to participate in this contest", + IsAdmitted: true, Payment: &payment, }, nil } From d670b3d3bb1d9b826ee4f180bae96fb816a95ea7 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 23 Nov 2025 05:56:28 +0400 Subject: [PATCH 55/56] chore: Improve validation, error handling, and consistency across contest, entry, and submission flows --- internal/app/distributor/distributor.go | 10 +- internal/app/handler/account.go | 2 +- internal/app/handler/contest.go | 14 ++- internal/app/handler/entry.go | 2 +- internal/app/handler/problem.go | 4 +- internal/app/handler/submission.go | 28 ++--- internal/app/router/router.go | 18 ++- internal/app/service/contest.go | 115 ++++++++++++------ internal/app/service/errors.go | 4 +- internal/app/service/submission.go | 6 +- internal/config/config.go | 6 +- internal/jwt/jwt.go | 3 +- .../repository/postgres/contest/contest.go | 3 +- 13 files changed, 137 insertions(+), 78 deletions(-) diff --git a/internal/app/distributor/distributor.go b/internal/app/distributor/distributor.go index 7412d7d..e1aa0da 100644 --- a/internal/app/distributor/distributor.go +++ b/internal/app/distributor/distributor.go @@ -2,10 +2,12 @@ package distributor import ( "context" + "errors" "fmt" "log/slog" "math/big" + "github.com/jackc/pgx/v5" "github.com/voidcontests/api/internal/lib/crypto" "github.com/voidcontests/api/internal/storage/models" "github.com/voidcontests/api/internal/storage/repository" @@ -37,7 +39,6 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc return err } - // Decrypt the mnemonic before using it decryptedMnemonic, err := cipher.Decrypt(w.MnemonicEncrypted) if err != nil { return fmt.Errorf("failed to decrypt mnemonic: %w", err) @@ -49,6 +50,10 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc } winnerID, err := r.Contest.GetWinnerID(ctx, c.ID) + if errors.Is(err, pgx.ErrNoRows) { + slog.Info("no users that submitted at least one ok solution", slog.Int("contest_id", c.ID)) + return nil + } if err != nil { return err } @@ -72,6 +77,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc return err } + // keep 2% for paying gas factor := 1 - 0.02 amount := tlb.FromNanoTON(big.NewInt(int64(float64(nanos) * factor))) tx, err := wallet.TransferTo(ctx, recepient, amount, fmt.Sprintf("contests.fckn.engineer: Prize for winning contest #%d", c.ID)) @@ -79,7 +85,7 @@ func distributeAwardForContest(ctx context.Context, r *repository.Repository, tc return err } - slog.Info("award distributed", slog.Any("contest_id", c.ID), slog.String("tx", tx)) + slog.Info("award distributed", slog.Int("contest_id", c.ID), slog.String("tx", tx)) paymentID, err := r.Payment.Create(ctx, tx, tc.GetAddress(wallet.Address()), tc.GetAddress(recepient), amount.Nano().Uint64(), false) if err != nil { diff --git a/internal/app/handler/account.go b/internal/app/handler/account.go index 8fe8590..561c68b 100644 --- a/internal/app/handler/account.go +++ b/internal/app/handler/account.go @@ -49,7 +49,7 @@ func (h *Handler) CreateSession(c echo.Context) error { token, err := h.service.Account.CreateSession(ctx, body.Username, body.Password) if err != nil { if errors.Is(err, service.ErrInvalidCredentials) { - return Error(http.StatusUnauthorized, "user not found") + return Error(http.StatusUnauthorized, "invalid credentials") } return err } diff --git a/internal/app/handler/contest.go b/internal/app/handler/contest.go index 88ba159..fe852db 100644 --- a/internal/app/handler/contest.go +++ b/internal/app/handler/contest.go @@ -41,6 +41,8 @@ func (h *Handler) CreateContest(c echo.Context) error { return Error(http.StatusForbidden, "you are banned from creating contests") case errors.Is(err, service.ErrContestsLimitExceeded): return Error(http.StatusForbidden, "contests limit exceeded") + case errors.Is(err, service.ErrInvalidContestTiming): + return Error(http.StatusBadRequest, "invalid contest timing: check start time, end time, and duration") default: return err } @@ -155,12 +157,12 @@ func (h *Handler) GetCreatedContests(c echo.Context) error { claims, _ := ExtractClaims(c) limit, ok := ExtractQueryParamInt(c, "limit") - if !ok { + if !ok || limit < 0 { limit = 10 } offset, ok := ExtractQueryParamInt(c, "offset") - if !ok { + if !ok || offset < 0 { offset = 0 } @@ -220,8 +222,8 @@ func (h *Handler) GetContests(c echo.Context) error { filters := models.ContestFilters{} if creatorID, ok := ExtractQueryParamInt(c, "creator_id"); ok { - if creatorID > 0 { - return Error(http.StatusBadRequest, "creator_id should be a valid integer, greater 0") + if creatorID <= 0 { + return Error(http.StatusBadRequest, "creator_id should be a valid integer, greater than 0") } filters.CreatorID = creatorID } @@ -279,12 +281,12 @@ func (h *Handler) GetScores(c echo.Context) error { } limit, ok := ExtractQueryParamInt(c, "limit") - if !ok { + if !ok || limit < 0 { limit = 50 } offset, ok := ExtractQueryParamInt(c, "offset") - if !ok { + if !ok || offset < 0 { offset = 0 } diff --git a/internal/app/handler/entry.go b/internal/app/handler/entry.go index ee160a6..95e0825 100644 --- a/internal/app/handler/entry.go +++ b/internal/app/handler/entry.go @@ -18,7 +18,7 @@ func (h *Handler) CreateEntry(c echo.Context) error { return Error(http.StatusBadRequest, "contest ID should be an integer") } - err := h.service.Contest.CreateEntry(ctx, contestID, claims.UserID) + _, err := h.service.Contest.CreateEntry(ctx, contestID, claims.UserID) if err != nil { switch { case errors.Is(err, service.ErrContestNotFound): diff --git a/internal/app/handler/problem.go b/internal/app/handler/problem.go index 3f7f491..1334bc0 100644 --- a/internal/app/handler/problem.go +++ b/internal/app/handler/problem.go @@ -58,12 +58,12 @@ func (h *Handler) GetCreatedProblems(c echo.Context) error { claims, _ := ExtractClaims(c) limit, ok := ExtractQueryParamInt(c, "limit") - if !ok { + if !ok || limit < 0 { limit = 10 } offset, ok := ExtractQueryParamInt(c, "offset") - if !ok { + if !ok || offset < 0 { offset = 0 } diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index 8b0e05d..ceb247a 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -2,20 +2,17 @@ package handler import ( "errors" - "log/slog" "net/http" "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/app/service" - "github.com/voidcontests/api/internal/lib/logger/sl" - "github.com/voidcontests/api/pkg/requestid" "github.com/voidcontests/api/pkg/validate" ) func (h *Handler) CreateSubmission(c echo.Context) error { - log := slog.With(slog.String("op", "handler.CreateSubmission"), slog.String("request_id", requestid.Get(c))) ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -29,7 +26,6 @@ func (h *Handler) CreateSubmission(c echo.Context) error { var body request.CreateSubmission if err := validate.Bind(c, &body); err != nil { - log.Debug("can't decode request body", sl.Err(err)) return Error(http.StatusBadRequest, "invalid body") } @@ -54,7 +50,6 @@ func (h *Handler) CreateSubmission(c echo.Context) error { case errors.Is(err, service.ErrProblemNotFound): return Error(http.StatusNotFound, "problem not found") default: - log.Error("failed to create submission", sl.Err(err)) return err } } @@ -70,24 +65,25 @@ func (h *Handler) CreateSubmission(c echo.Context) error { } func (h *Handler) GetSubmissionByID(c echo.Context) error { - log := slog.With(slog.String("op", "handler.GetSubmissionByID"), slog.String("request_id", requestid.Get(c))) ctx := c.Request().Context() - // TODO: check if submission is submitted by request initiator - _, _ = ExtractClaims(c) + claims, _ := ExtractClaims(c) submissionID, ok := ExtractParamInt(c, "sid") if !ok { return Error(http.StatusBadRequest, "submission ID should be an integer") } - details, err := h.service.Submission.GetSubmissionByID(ctx, submissionID) + details, err := h.service.Submission.GetSubmissionByID(ctx, submissionID, claims.UserID) if err != nil { - if errors.Is(err, service.ErrSubmissionNotFound) { + switch { + case errors.Is(err, service.ErrSubmissionNotFound): return Error(http.StatusNotFound, "submission not found") + case errors.Is(err, service.ErrUnauthorizedAccess): + return Error(http.StatusNotFound, "submission not found") + default: + return err } - log.Error("failed to get submission", sl.Err(err)) - return err } submission := details.Submission @@ -153,7 +149,6 @@ func (h *Handler) GetSubmissionByID(c echo.Context) error { } func (h *Handler) GetSubmissions(c echo.Context) error { - log := slog.With(slog.String("op", "handler.GetSubmissions"), slog.String("request_id", requestid.Get(c))) ctx := c.Request().Context() claims, _ := ExtractClaims(c) @@ -166,12 +161,12 @@ func (h *Handler) GetSubmissions(c echo.Context) error { charcode := c.Param("charcode") limit, ok := ExtractQueryParamInt(c, "limit") - if !ok { + if !ok || limit < 0 { limit = 10 } offset, ok := ExtractQueryParamInt(c, "offset") - if !ok { + if !ok || offset < 0 { offset = 0 } @@ -183,7 +178,6 @@ func (h *Handler) GetSubmissions(c echo.Context) error { case errors.Is(err, service.ErrNoEntryForContest): return Error(http.StatusForbidden, "no entry for contest") default: - log.Error("failed to list submissions", sl.Err(err)) return err } } diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 6bafa5f..61a7c5f 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -54,6 +54,14 @@ func (r *Router) InitRoutes() *echo.Echo { }) } + // TODO: update rate limiting logic: + // Current: + // - request -> wait Ns -> request + // + // Expected: + // - [request -> request -> request] - in such window, forbid to make more than M requests + // ^ 0s Ns ^ + api := router.Group("/api") { api.GET("/healthcheck", r.handler.Healthcheck) @@ -63,22 +71,22 @@ func (r *Router) InitRoutes() *echo.Echo { tonproof.POST("/check", r.handler.CheckProof, r.handler.MustIdentify()) api.GET("/account", r.handler.GetAccount, r.handler.MustIdentify()) - api.POST("/account", r.handler.CreateAccount) + api.POST("/account", r.handler.CreateAccount, ratelimit.WithTimeout(5*time.Second)) api.PATCH("/account", r.handler.UpdateAccount, r.handler.MustIdentify()) - api.POST("/session", r.handler.CreateSession) + api.POST("/session", r.handler.CreateSession, ratelimit.WithTimeout(2*time.Second)) api.GET("/account/contests", r.handler.GetCreatedContests, r.handler.MustIdentify()) api.GET("/account/problems", r.handler.GetCreatedProblems, r.handler.MustIdentify()) - api.POST("/problems", r.handler.CreateProblem, r.handler.MustIdentify()) + api.POST("/problems", r.handler.CreateProblem, ratelimit.WithTimeout(3*time.Second), r.handler.MustIdentify()) api.GET("/problems/:pid", r.handler.GetProblemByID, r.handler.MustIdentify()) api.GET("/contests", r.handler.GetContests) - api.POST("/contests", r.handler.CreateContest, r.handler.MustIdentify()) + api.POST("/contests", r.handler.CreateContest, ratelimit.WithTimeout(3*time.Second), r.handler.MustIdentify()) api.GET("/contests/:cid", r.handler.GetContestByID, r.handler.TryIdentify()) - api.POST("/contests/:cid/entry", r.handler.CreateEntry, r.handler.MustIdentify()) + api.POST("/contests/:cid/entry", r.handler.CreateEntry, ratelimit.WithTimeout(3*time.Second), r.handler.MustIdentify()) api.GET("/contests/:cid/scores", r.handler.GetScores) api.GET("/contests/:cid/problems/:charcode", r.handler.GetContestProblem, r.handler.MustIdentify()) diff --git a/internal/app/service/contest.go b/internal/app/service/contest.go index 46a9f8b..7bc68ba 100644 --- a/internal/app/service/contest.go +++ b/internal/app/service/contest.go @@ -49,6 +49,22 @@ type CreateContestParams struct { func (s *ContestService) CreateContest(ctx context.Context, params CreateContestParams) (int, error) { op := "service.ContestService.CreateContest" + now := time.Now() + if params.StartTime.Before(now) { + return 0, ErrInvalidContestTiming + } + if !params.StartTime.Before(params.EndTime) { + return 0, ErrInvalidContestTiming + } + if params.DurationMins < 0 { + return 0, ErrInvalidContestTiming + } + + contestLengthMins := int(params.EndTime.Sub(params.StartTime).Minutes()) + if params.DurationMins > 0 && params.DurationMins > contestLengthMins { + return 0, ErrInvalidContestTiming + } + userRole, err := s.repo.User.GetRole(ctx, params.UserID) if err != nil { return 0, fmt.Errorf("%s: failed to get user role: %w", op, err) @@ -188,8 +204,11 @@ func (s *ContestService) GetContestByID(ctx context.Context, contestID int, user now := time.Now() + // Registration is closed if: + // 1. Contest has ended + // 2. Contest has started and late join is not allowed isRegistrationOpen := true - if contest.StartTime.Before(now) && contest.EndTime.After(now) && !contest.AllowLateJoin { + if contest.EndTime.Before(now) || (contest.StartTime.Before(now) && !contest.AllowLateJoin) { isRegistrationOpen = false } details := &ContestDetails{ @@ -386,7 +405,7 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry } amount := tlb.FromNanoTONU(contest.EntryPriceTonNanos) - tx, exists := s.ton.LookupTx(ctx, from, to, amount) + transaction, exists := s.ton.LookupTx(ctx, from, to, amount) if !exists { return EntryDetails{ Entry: entry, @@ -395,20 +414,31 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry }, nil } - pid, err := s.repo.Payment.Create(ctx, tx, s.ton.GetAddress(from), wallet.Address, contest.EntryPriceTonNanos, true) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to create payment: %w", op, err) - } + var payment models.Payment + err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + repo := repository.NewTxRepository(tx) - err = s.repo.Entry.SetPaymentID(ctx, entry.ID, pid) - if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to set payment ID for entry: %w", op, err) - } - entry.PaymentID = &pid + pid, err := repo.Payment.Create(ctx, transaction, s.ton.GetAddress(from), wallet.Address, contest.EntryPriceTonNanos, true) + if err != nil { + return fmt.Errorf("failed to create payment: %w", err) + } + + err = repo.Entry.SetPaymentID(ctx, entry.ID, pid) + if err != nil { + return fmt.Errorf("failed to set payment ID for entry: %w", err) + } + entry.PaymentID = &pid + + payment, err = repo.Payment.GetByID(ctx, pid) + if err != nil { + return fmt.Errorf("failed to get payment by ID: %w", err) + } + + return nil + }) - payment, err := s.repo.Payment.GetByID(ctx, pid) if err != nil { - return EntryDetails{}, fmt.Errorf("%s: failed to get payment by ID: %w", op, err) + return EntryDetails{}, fmt.Errorf("%s: %w", op, err) } return EntryDetails{ @@ -418,47 +448,58 @@ func (s *ContestService) getEntryDetails(ctx context.Context, entry models.Entry }, nil } -func (s *ContestService) CreateEntry(ctx context.Context, contestID int, userID int) error { +func (s *ContestService) CreateEntry(ctx context.Context, contestID int, userID int) (int, error) { op := "service.ContestService.CreateEntry" contest, err := s.repo.Contest.GetByID(ctx, contestID) if errors.Is(err, pgx.ErrNoRows) { - return ErrContestNotFound + return 0, ErrContestNotFound } if err != nil { - return fmt.Errorf("%s: failed to get contest: %w", op, err) + return 0, fmt.Errorf("%s: failed to get contest: %w", op, err) } if contest.CreatorID == userID { - return ErrCannotJoinOwnContest - } - - entriesCount, err := s.repo.Contest.GetEntriesCount(ctx, contestID) - if err != nil { - return fmt.Errorf("%s: failed to get entries count: %w", op, err) - } - - if contest.MaxEntries != 0 && entriesCount >= contest.MaxEntries { - return ErrMaxSlotsReached + return 0, ErrCannotJoinOwnContest } now := time.Now() if contest.EndTime.Before(now) || (contest.StartTime.Before(now) && !contest.AllowLateJoin) { - return ErrApplicationTimeOver + return 0, ErrApplicationTimeOver } - _, err = s.repo.Entry.Get(ctx, contestID, userID) - if err == nil { - return ErrEntryAlreadyExists - } - if !errors.Is(err, pgx.ErrNoRows) { - return fmt.Errorf("%s: failed to check existing entry: %w", op, err) - } + var entryID int + err = s.repo.TxManager.WithinTransaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + repo := repository.NewTxRepository(tx) + + _, err := repo.Entry.Get(ctx, contestID, userID) + if err == nil { + return ErrEntryAlreadyExists + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("failed to check existing entry: %w", err) + } + + entriesCount, err := repo.Contest.GetEntriesCount(ctx, contestID) + if err != nil { + return fmt.Errorf("failed to get entries count: %w", err) + } + + if contest.MaxEntries != 0 && entriesCount >= contest.MaxEntries { + return ErrMaxSlotsReached + } + + entryID, err = repo.Entry.Create(ctx, contestID, userID) + if err != nil { + return fmt.Errorf("failed to create entry: %w", err) + } + + return nil + }) - _, err = s.repo.Entry.Create(ctx, contestID, userID) if err != nil { - return fmt.Errorf("%s: failed to create entry: %w", op, err) + return 0, fmt.Errorf("%s: %w", op, err) } - return nil + return entryID, nil } diff --git a/internal/app/service/errors.go b/internal/app/service/errors.go index 9438788..9685f0f 100644 --- a/internal/app/service/errors.go +++ b/internal/app/service/errors.go @@ -11,7 +11,8 @@ var ( ErrInvalidToken = errors.New("invalid or expired token") // contest - ErrUnknownAwardType = errors.New("unknown award type") + ErrUnknownAwardType = errors.New("unknown award type") + ErrInvalidContestTiming = errors.New("invalid contest timing") // entry ErrContestFinished = errors.New("contest not found") @@ -38,6 +39,7 @@ var ( ErrSubmissionWindowClosed = errors.New("submission window is currently closed") ErrSubmissionNotFound = errors.New("submission not found") ErrInvalidCharcode = errors.New("problem's charcode couldn't be longer than 2 characters") + ErrUnauthorizedAccess = errors.New("unauthorized access to this resource") // tonproof ErrTonProofFailed = errors.New("tonproof verification failed") diff --git a/internal/app/service/submission.go b/internal/app/service/submission.go index dd1212b..33c738a 100644 --- a/internal/app/service/submission.go +++ b/internal/app/service/submission.go @@ -96,7 +96,7 @@ type SubmissionDetails struct { FailedTest *models.TestCase } -func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID int) (*SubmissionDetails, error) { +func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID int, userID int) (*SubmissionDetails, error) { op := "service.SubmissionService.GetSubmissionByID" submission, err := s.repo.Submission.GetByID(ctx, submissionID) @@ -107,6 +107,10 @@ func (s *SubmissionService) GetSubmissionByID(ctx context.Context, submissionID return nil, fmt.Errorf("%s: failed to get submission: %w", op, err) } + if submission.UserID != userID { + return nil, ErrUnauthorizedAccess + } + details := &SubmissionDetails{ Submission: submission, } diff --git a/internal/config/config.go b/internal/config/config.go index 75df2ce..c53f5cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,9 +32,9 @@ type Server struct { } type Security struct { - SignatureKey string `yaml:"signature_key" env-required:"true"` - Salt string `yaml:"salt" env-required:"true"` - WalletEncryptKey string `yaml:"wallet_encrypt_key" env-required:"true"` + SignatureKey string `yaml:"signature_key" env-required:"true"` + Salt string `yaml:"salt" env-required:"true"` + WalletEncryptKey string `yaml:"wallet_encrypt_key" env-required:"true"` } type Postgres struct { diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go index a3116e1..2b11235 100644 --- a/internal/jwt/jwt.go +++ b/internal/jwt/jwt.go @@ -13,9 +13,10 @@ type CustomClaims struct { } func GenerateToken(id int, secret string) (string, error) { + expiration := 7 * 24 * time.Hour // 7 days claims := &CustomClaims{ jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().AddDate(100, 0, 0)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)), }, id, } diff --git a/internal/storage/repository/postgres/contest/contest.go b/internal/storage/repository/postgres/contest/contest.go index 977c6c7..4ea1bbc 100644 --- a/internal/storage/repository/postgres/contest/contest.go +++ b/internal/storage/repository/postgres/contest/contest.go @@ -268,8 +268,9 @@ func (p *Postgres) IsTitleOccupied(ctx context.Context, title string) (bool, err } func (p *Postgres) GetWinnerID(ctx context.Context, contestID int) (int, error) { + // TODO: tie-breaking rules query := ` SELECT user_id FROM scores - WHERE contest_id = $1 ORDER BY points DESC LIMIT 1` + WHERE contest_id = $1 AND points > 0 ORDER BY points DESC LIMIT 1` var userID int err := p.conn.QueryRow(ctx, query, contestID).Scan(&userID) From d36a5cbeeb884f423b6bb1c6e417c2b581642948 Mon Sep 17 00:00:00 2001 From: jus1d Date: Sun, 23 Nov 2025 06:02:17 +0400 Subject: [PATCH 56/56] deps: Update `crypto` to 0.45.0 --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- internal/app/handler/submission.go | 2 -- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 9498b74..d5ccc3a 100644 --- a/go.mod +++ b/go.mod @@ -35,12 +35,12 @@ require ( github.com/snksoft/crc v1.1.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.42.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20230116083435-1de6713980de // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.8.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect diff --git a/go.sum b/go.sum index 86ae40d..89b7caa 100644 --- a/go.sum +++ b/go.sum @@ -68,20 +68,20 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xssnick/tonutils-go v1.15.5 h1:yAcHnDaY5QW0aIQE47lT0PuDhhHYE+N+NyZssdPKR0s= github.com/xssnick/tonutils-go v1.15.5/go.mod h1:3/B8mS5IWLTd1xbGbFbzRem55oz/Q86HG884bVsTqZ8= -golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= -golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20230116083435-1de6713980de h1:DBWn//IJw30uYCgERoxCg84hWtA97F4wMiKOIh00Uf0= golang.org/x/exp v0.0.0-20230116083435-1de6713980de/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app/handler/submission.go b/internal/app/handler/submission.go index ceb247a..8bc2e11 100644 --- a/internal/app/handler/submission.go +++ b/internal/app/handler/submission.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/labstack/echo/v4" - "github.com/labstack/gommon/log" "github.com/voidcontests/api/internal/app/handler/dto/request" "github.com/voidcontests/api/internal/app/handler/dto/response" "github.com/voidcontests/api/internal/app/service" @@ -43,7 +42,6 @@ func (h *Handler) CreateSubmission(c echo.Context) error { case errors.Is(err, service.ErrContestNotFound): return Error(http.StatusNotFound, "contest not found") case errors.Is(err, service.ErrNoEntryForContest): - log.Debug("trying to create submission without entry") return Error(http.StatusForbidden, "no entry for contest") case errors.Is(err, service.ErrSubmissionWindowClosed): return Error(http.StatusForbidden, "submission window is currently closed")