diff --git a/db/migrations/000025_add_valorant_fields_to_contests_members.down.sql b/db/migrations/000025_add_valorant_fields_to_contests_members.down.sql new file mode 100644 index 0000000..9d02795 --- /dev/null +++ b/db/migrations/000025_add_valorant_fields_to_contests_members.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE contests_members + DROP COLUMN valorant_roles, + DROP COLUMN description; diff --git a/db/migrations/000025_add_valorant_fields_to_contests_members.up.sql b/db/migrations/000025_add_valorant_fields_to_contests_members.up.sql new file mode 100644 index 0000000..00b3e5b --- /dev/null +++ b/db/migrations/000025_add_valorant_fields_to_contests_members.up.sql @@ -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; diff --git a/internal/contest/application/contest_application_service.go b/internal/contest/application/contest_application_service.go index ff8e945..9c6f3e0 100644 --- a/internal/contest/application/contest_application_service.go +++ b/internal/contest/application/contest_application_service.go @@ -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 { + return nil, exception.ErrDescriptionTooLong + } + if len(req.ValorantRoles) > 0 { + roles := domain.ValorantRoles(req.ValorantRoles) + if !roles.AreValid() { + return nil, exception.ErrInvalidValorantRole + } + } + } + // Check if user has linked Discord account _, err := s.oauth2Repository.FindDiscordAccountByUserId(userId) if err != nil { @@ -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 @@ -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) } diff --git a/internal/contest/application/dto/application_dto.go b/internal/contest/application/dto/application_dto.go index 0f5c8e7..dad5d6f 100644 --- a/internal/contest/application/dto/application_dto.go +++ b/internal/contest/application/dto/application_dto.go @@ -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 { @@ -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, } } diff --git a/internal/contest/application/dto/contest_dto.go b/internal/contest/application/dto/contest_dto.go index 5210d78..c8d6eeb 100644 --- a/internal/contest/application/dto/contest_dto.go +++ b/internal/contest/application/dto/contest_dto.go @@ -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 @@ -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, diff --git a/internal/contest/application/port/contest_application_redis_port.go b/internal/contest/application/port/contest_application_redis_port.go index 3ceb43b..340684d 100644 --- a/internal/contest/application/port/contest_application_redis_port.go +++ b/internal/contest/application/port/contest_application_redis_port.go @@ -3,6 +3,8 @@ package port import ( "context" "time" + + "github.com/FOR-GAMERS/GAMERS-BE/internal/contest/domain" ) type ApplicationStatus string @@ -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 { diff --git a/internal/contest/application/port/contest_member_database_port.go b/internal/contest/application/port/contest_member_database_port.go index 8b7ef4a..03af53f 100644 --- a/internal/contest/application/port/contest_member_database_port.go +++ b/internal/contest/application/port/contest_member_database_port.go @@ -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 diff --git a/internal/contest/domain/contest_member.go b/internal/contest/domain/contest_member.go index 4716e13..803fae7 100644 --- a/internal/contest/domain/contest_member.go +++ b/internal/contest/domain/contest_member.go @@ -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 +} + type MemberType string const ( @@ -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 { diff --git a/internal/contest/infra/persistence/adapter/contest_member_database_adapter.go b/internal/contest/infra/persistence/adapter/contest_member_database_adapter.go index ed16c15..4df4092 100644 --- a/internal/contest/infra/persistence/adapter/contest_member_database_adapter.go +++ b/internal/contest/infra/persistence/adapter/contest_member_database_adapter.go @@ -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). diff --git a/internal/contest/presentation/contest_application_controller.go b/internal/contest/presentation/contest_application_controller.go index 0a819df..64b77b4 100644 --- a/internal/contest/presentation/contest_application_controller.go +++ b/internal/contest/presentation/contest_application_controller.go @@ -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 @@ -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) + + discordLinkRequired, err := c.service.RequestParticipate(ctx.Request.Context(), contestId, userId, &req) // Handle Discord link required error if errors.Is(err, exception.ErrDiscordLinkRequired) { diff --git a/internal/global/exception/contest_error_status.go b/internal/global/exception/contest_error_status.go index 0a5ba27..13f70fe 100644 --- a/internal/global/exception/contest_error_status.go +++ b/internal/global/exception/contest_error_status.go @@ -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") )