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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE contests_members
DROP COLUMN valorant_roles,
DROP COLUMN description;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE contests_members
ADD COLUMN valorant_roles JSON DEFAULT NULL AFTER point,
ADD COLUMN description VARCHAR(64) DEFAULT '' AFTER valorant_roles;
44 changes: 28 additions & 16 deletions internal/contest/application/contest_application_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,20 @@ func (s *ContestApplicationService) SetScoreTablePort(scoreTableRepo pointPort.V
}

// RequestParticipate - Contest 참가 신청
func (s *ContestApplicationService) RequestParticipate(ctx context.Context, contestId, userId int64) (*dto.DiscordLinkRequiredResponse, error) {
func (s *ContestApplicationService) RequestParticipate(ctx context.Context, contestId, userId int64, req *dto.RequestParticipateRequest) (*dto.DiscordLinkRequiredResponse, error) {
// Validate request if provided
if req != nil {
if len(req.Description) > 64 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The value 64 is a magic number representing the maximum length of the description. This value is also hardcoded in the database migration (VARCHAR(64)). To improve maintainability and ensure consistency, it's best practice to define this as a constant (e.g., domain.MaxDescriptionLength) and reuse it across the application.

return nil, exception.ErrDescriptionTooLong
}
if len(req.ValorantRoles) > 0 {
roles := domain.ValorantRoles(req.ValorantRoles)
if !roles.AreValid() {
return nil, exception.ErrInvalidValorantRole
}
}
Comment on lines +67 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This validation logic returns a generic ErrInvalidValorantRole for any issue with the roles. To provide more specific feedback, the AreValid method in the domain should return a distinct error for duplicate roles (as ErrDuplicateValorantRole has been defined). This requires updating the AreValid method to return an error and then handling that error here.

Suggested change
if len(req.ValorantRoles) > 0 {
roles := domain.ValorantRoles(req.ValorantRoles)
if !roles.AreValid() {
return nil, exception.ErrInvalidValorantRole
}
}
if len(req.ValorantRoles) > 0 {
roles := domain.ValorantRoles(req.ValorantRoles)
if err := roles.AreValid(); err != nil {
return nil, err
}
}

}

// Check if user has linked Discord account
_, err := s.oauth2Repository.FindDiscordAccountByUserId(userId)
if err != nil {
Expand Down Expand Up @@ -118,6 +131,12 @@ func (s *ContestApplicationService) RequestParticipate(ctx context.Context, cont
PeakTier: user.GetPeakTierFullName(),
}

// Add valorant roles and description if provided
if req != nil {
senderSnapshot.ValorantRoles = req.ValorantRoles
senderSnapshot.Description = req.Description
}

ttl := time.Until(contest.StartedAt)
if ttl < 0 {
ttl = 24 * time.Hour
Expand Down Expand Up @@ -161,29 +180,22 @@ func (s *ContestApplicationService) AcceptApplication(ctx context.Context, conte
return err
}

// Get the application to retrieve stored point before accepting
application, err := s.applicationRepo.GetApplication(ctx, contestId, userId)
if err != nil {
return err
}
if application == nil {
return exception.ErrApplicationNotFound
}
if application.Status != port.ApplicationStatusPending {
return exception.ErrApplicationNotPending
}
// Get application data before accepting (to preserve valorant roles and description)
application, appErr := s.applicationRepo.GetApplication(ctx, contestId, userId)

err = s.applicationRepo.AcceptRequest(ctx, contestId, userId, leaderUserId)
if err != nil {
return err
}

memberPoint := 0
if application != nil && application.Sender != nil {
memberPoint = application.Sender.Point
member := domain.NewContestMember(userId, contestId, domain.MemberTypeNormal, domain.LeaderTypeMember)

// Copy valorant roles and description from application sender snapshot
if appErr == nil && application != nil && application.Sender != nil {
member.ValorantRoles = application.Sender.ValorantRoles
member.Description = application.Sender.Description
}

member := domain.NewContestMember(userId, contestId, domain.MemberTypeNormal, domain.LeaderTypeMember, memberPoint)
if err := s.memberRepo.Save(member); err != nil {
return fmt.Errorf("[AcceptApplication] failed to save member (contestId=%d, userId=%d): %w", contestId, userId, err)
}
Expand Down
33 changes: 19 additions & 14 deletions internal/contest/application/dto/application_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ package dto

import (
"github.com/FOR-GAMERS/GAMERS-BE/internal/contest/application/port"
"github.com/FOR-GAMERS/GAMERS-BE/internal/contest/domain"
"time"
)

// RequestParticipateRequest represents the request body for contest participation
type RequestParticipateRequest struct {
ValorantRoles []domain.ValorantRole `json:"valorant_roles,omitempty"`
Description string `json:"description,omitempty"`
}

type SenderResponse struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar,omitempty"`
Point int `json:"point"`
CurrentTier string `json:"current_tier,omitempty"`
PeakTier string `json:"peak_tier,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar,omitempty"`
ValorantRoles []domain.ValorantRole `json:"valorant_roles,omitempty"`
Description string `json:"description,omitempty"`
}

type ApplicationResponse struct {
Expand All @@ -29,13 +35,12 @@ func ToApplicationResponse(app *port.ContestApplication) *ApplicationResponse {
var sender *SenderResponse
if app.Sender != nil {
sender = &SenderResponse{
UserID: app.Sender.UserID,
Username: app.Sender.Username,
Tag: app.Sender.Tag,
Avatar: app.Sender.Avatar,
Point: app.Sender.Point,
CurrentTier: app.Sender.CurrentTier,
PeakTier: app.Sender.PeakTier,
UserID: app.Sender.UserID,
Username: app.Sender.Username,
Tag: app.Sender.Tag,
Avatar: app.Sender.Avatar,
ValorantRoles: app.Sender.ValorantRoles,
Description: app.Sender.Description,
}
}

Expand Down
28 changes: 16 additions & 12 deletions internal/contest/application/dto/contest_dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,20 @@ func (req *UpdateContestRequest) Validate() error {

// ContestMemberResponse represents a contest member with user information
type ContestMemberResponse struct {
UserID int64 `json:"user_id"`
ContestID int64 `json:"contest_id"`
MemberType domain.MemberType `json:"member_type"`
LeaderType domain.LeaderType `json:"leader_type"`
Point int `json:"point"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
CurrentTier *int `json:"current_tier,omitempty"`
CurrentTierPatched *string `json:"current_tier_patched,omitempty"`
PeakTier *int `json:"peak_tier,omitempty"`
PeakTierPatched *string `json:"peak_tier_patched,omitempty"`
UserID int64 `json:"user_id"`
ContestID int64 `json:"contest_id"`
MemberType domain.MemberType `json:"member_type"`
LeaderType domain.LeaderType `json:"leader_type"`
Point int `json:"point"`
ValorantRoles []domain.ValorantRole `json:"valorant_roles,omitempty"`
Description string `json:"description,omitempty"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
CurrentTier *int `json:"current_tier,omitempty"`
CurrentTierPatched *string `json:"current_tier_patched,omitempty"`
PeakTier *int `json:"peak_tier,omitempty"`
PeakTierPatched *string `json:"peak_tier_patched,omitempty"`
}

// ToContestMemberResponse converts port.ContestMemberWithUser to ContestMemberResponse
Expand All @@ -189,6 +191,8 @@ func ToContestMemberResponse(member *port.ContestMemberWithUser) *ContestMemberR
MemberType: member.MemberType,
LeaderType: member.LeaderType,
Point: member.Point,
ValorantRoles: member.ValorantRoles,
Description: member.Description,
Username: member.Username,
Tag: member.Tag,
Avatar: avatar,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package port
import (
"context"
"time"

"github.com/FOR-GAMERS/GAMERS-BE/internal/contest/domain"
)

type ApplicationStatus string
Expand All @@ -15,13 +17,12 @@ const (

// SenderSnapshot stores user information at the time of application
type SenderSnapshot struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar,omitempty"`
Point int `json:"point"`
CurrentTier string `json:"current_tier,omitempty"`
PeakTier string `json:"peak_tier,omitempty"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar,omitempty"`
ValorantRoles domain.ValorantRoles `json:"valorant_roles,omitempty"`
Description string `json:"description,omitempty"`
}

type ContestApplication struct {
Expand Down
30 changes: 16 additions & 14 deletions internal/contest/application/port/contest_member_database_port.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@ import (

// ContestMemberWithUser represents a contest member with user information
type ContestMemberWithUser struct {
UserID int64 `json:"user_id"`
ContestID int64 `json:"contest_id"`
MemberType domain.MemberType `json:"member_type"`
LeaderType domain.LeaderType `json:"leader_type"`
Point int `json:"point"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
DiscordId *string `json:"discord_id"`
DiscordAvatar *string `json:"discord_avatar"`
CurrentTier *int `json:"current_tier"`
CurrentTierPatched *string `json:"current_tier_patched"`
PeakTier *int `json:"peak_tier"`
PeakTierPatched *string `json:"peak_tier_patched"`
UserID int64 `json:"user_id"`
ContestID int64 `json:"contest_id"`
MemberType domain.MemberType `json:"member_type"`
LeaderType domain.LeaderType `json:"leader_type"`
Point int `json:"point"`
ValorantRoles domain.ValorantRoles `json:"valorant_roles"`
Description string `json:"description"`
Username string `json:"username"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
DiscordId *string `json:"discord_id"`
DiscordAvatar *string `json:"discord_avatar"`
CurrentTier *int `json:"current_tier"`
CurrentTierPatched *string `json:"current_tier_patched"`
PeakTier *int `json:"peak_tier"`
PeakTierPatched *string `json:"peak_tier_patched"`
}

// ContestWithMembership represents a contest with the user's membership info
Expand Down
86 changes: 81 additions & 5 deletions internal/contest/domain/contest_member.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,83 @@
package domain

import (
"database/sql/driver"
"encoding/json"
"fmt"

"github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception"
)

// ValorantRole represents a Valorant agent role type
type ValorantRole string

const (
ValorantRoleDuelist ValorantRole = "DUELIST"
ValorantRoleInitiator ValorantRole = "INITIATOR"
ValorantRoleController ValorantRole = "CONTROLLER"
ValorantRoleSentinel ValorantRole = "SENTINEL"
)

// IsValid checks if the ValorantRole is valid
func (vr ValorantRole) IsValid() bool {
switch vr {
case ValorantRoleDuelist, ValorantRoleInitiator, ValorantRoleController, ValorantRoleSentinel:
return true
default:
return false
}
}

// ValorantRoles is a custom type for storing []ValorantRole as JSON in the database
type ValorantRoles []ValorantRole

// Scan implements the sql.Scanner interface for reading from DB
func (vr *ValorantRoles) Scan(value interface{}) error {
if value == nil {
*vr = nil
return nil
}

var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("unsupported type for ValorantRoles: %T", value)
}

return json.Unmarshal(bytes, vr)
}

// Value implements the driver.Valuer interface for writing to DB
func (vr ValorantRoles) Value() (driver.Value, error) {
if vr == nil {
return nil, nil
}
bytes, err := json.Marshal(vr)
if err != nil {
return nil, err
}
return string(bytes), nil
}

// AreValid checks if all roles in the slice are valid and there are no duplicates
func (vr ValorantRoles) AreValid() bool {
seen := make(map[ValorantRole]bool)
for _, role := range vr {
if !role.IsValid() {
return false
}
if seen[role] {
return false
}
seen[role] = true
}
return true
}
Comment on lines +67 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The AreValid method currently returns a bool, which prevents the caller from distinguishing between an invalid role and a duplicate role. A specific error, ErrDuplicateValorantRole, has been defined but is not used because of this. To provide more specific feedback to the user, this method should be changed to return an error. This will allow the service layer to return the correct error code. Additionally, using map[ValorantRole]struct{} is more idiomatic in Go for implementing a set.

Suggested change
func (vr ValorantRoles) AreValid() bool {
seen := make(map[ValorantRole]bool)
for _, role := range vr {
if !role.IsValid() {
return false
}
if seen[role] {
return false
}
seen[role] = true
}
return true
}
func (vr ValorantRoles) AreValid() error {
seen := make(map[ValorantRole]struct{})
for _, role := range vr {
if !role.IsValid() {
return exception.ErrInvalidValorantRole
}
if _, exists := seen[role]; exists {
return exception.ErrDuplicateValorantRole
}
seen[role] = struct{}{}
}
return nil
}


type MemberType string

const (
Expand All @@ -29,11 +103,13 @@ const (
)

type ContestMember struct {
UserID int64 `gorm:"column:user_id;primaryKey" json:"user_id"`
ContestID int64 `gorm:"column:contest_id;primaryKey" json:"contest_id"`
MemberType MemberType `gorm:"column:member_type;type:varchar(16);not null" json:"member_type"`
LeaderType LeaderType `gorm:"column:leader_type;type:varchar(8);not null" json:"leader_type"`
Point int `gorm:"column:point;type:int;default:0" json:"point"`
UserID int64 `gorm:"column:user_id;primaryKey" json:"user_id"`
ContestID int64 `gorm:"column:contest_id;primaryKey" json:"contest_id"`
MemberType MemberType `gorm:"column:member_type;type:varchar(16);not null" json:"member_type"`
LeaderType LeaderType `gorm:"column:leader_type;type:varchar(8);not null" json:"leader_type"`
Point int `gorm:"column:point;type:int;default:0" json:"point"`
ValorantRoles ValorantRoles `gorm:"column:valorant_roles;type:json" json:"valorant_roles,omitempty"`
Description string `gorm:"column:description;type:varchar(64)" json:"description,omitempty"`
}

func NewContestMemberAsLeader(userID, contestID int64) *ContestMember {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ func (c ContestMemberDatabaseAdapter) GetMembersWithUserByContest(
// Query with JOIN (including discord_accounts for avatar URL)
var results []*port.ContestMemberWithUser
query := c.db.Table("contests_members cm").
Select("cm.user_id, cm.contest_id, cm.member_type, cm.leader_type, cm.point, u.username, u.tag, u.avatar, da.discord_id, da.discord_avatar, u.current_tier, u.current_tier_patched, u.peak_tier, u.peak_tier_patched").
Select("cm.user_id, cm.contest_id, cm.member_type, cm.leader_type, cm.point, cm.valorant_roles, cm.description, u.username, u.tag, u.avatar, da.discord_id, da.discord_avatar, u.current_tier, u.current_tier_patched, u.peak_tier, u.peak_tier_patched").
Joins("JOIN users u ON cm.user_id = u.id").
Joins("LEFT JOIN discord_accounts da ON u.id = da.user_id").
Where("cm.contest_id = ?", contestId).
Expand Down
10 changes: 8 additions & 2 deletions internal/contest/presentation/contest_application_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ func (c *ContestApplicationController) RegisterRoute() {

// RequestParticipate godoc
// @Summary Request to participate in a contest
// @Description Apply to participate in a contest
// @Description Apply to participate in a contest with optional valorant roles and description
// @Tags contest-applications
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param contestId path int true "Contest ID"
// @Param request body dto.RequestParticipateRequest false "Participation request with optional valorant roles and description"
// @Success 201 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
Expand All @@ -81,7 +82,12 @@ func (c *ContestApplicationController) RequestParticipate(ctx *gin.Context) {
return
}

discordLinkRequired, err := c.service.RequestParticipate(ctx.Request.Context(), contestId, userId)
// Parse optional request body
var req dto.RequestParticipateRequest
// ShouldBindJSON returns error if body exists but is invalid; nil body is OK
_ = ctx.ShouldBindJSON(&req)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The error returned by ctx.ShouldBindJSON(&req) is being ignored. While an empty request body is acceptable, a malformed JSON body will cause ShouldBindJSON to return an error. By ignoring it, the server will proceed as if no body was sent, which is confusing for API clients who provided a body and expected it to be processed. The error should be handled to return a 400 Bad Request for invalid JSON. Note that ShouldBindJSON returns io.EOF for an empty body, which should be explicitly ignored.

Suggested change
_ = ctx.ShouldBindJSON(&req)
if err := ctx.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.JSON(ctx, response.BadRequest("invalid request body: "+err.Error()))
return
}


discordLinkRequired, err := c.service.RequestParticipate(ctx.Request.Context(), contestId, userId, &req)

// Handle Discord link required error
if errors.Is(err, exception.ErrDiscordLinkRequired) {
Expand Down
4 changes: 3 additions & 1 deletion internal/global/exception/contest_error_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,7 @@ var (
ErrContestNotActive = NewBadRequestError("contest is not in active status", "CT032")
ErrCannotChangeLeaderRole = NewBusinessError(http.StatusForbidden, "cannot change leader's role", "CT033")
ErrAlreadySameMemberType = NewBadRequestError("member already has the same role", "CT034")
ErrContestStartTimePassed = NewBadRequestError("contest start time has already passed", "CT035")
ErrInvalidValorantRole = NewBadRequestError("invalid valorant role", "CT035")
ErrDescriptionTooLong = NewBadRequestError("description must be at most 64 characters", "CT036")
ErrDuplicateValorantRole = NewBadRequestError("duplicate valorant role", "CT037")
Comment on lines +41 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

There is an inconsistency in the error codes between this file and the PR description. The description states CT035 for ErrDescriptionTooLong and CT036 for ErrInvalidValorantRole, but the codes are swapped here. To maintain consistency and avoid confusion for API consumers, the codes should be corrected to match the documentation.

Suggested change
ErrInvalidValorantRole = NewBadRequestError("invalid valorant role", "CT035")
ErrDescriptionTooLong = NewBadRequestError("description must be at most 64 characters", "CT036")
ErrDuplicateValorantRole = NewBadRequestError("duplicate valorant role", "CT037")
ErrInvalidValorantRole = NewBadRequestError("invalid valorant role", "CT036")
ErrDescriptionTooLong = NewBadRequestError("description must be at most 64 characters", "CT035")
ErrDuplicateValorantRole = NewBadRequestError("duplicate valorant role", "CT037")

)