diff --git a/cmd/server.go b/cmd/server.go index 6f8875e..99fa1db 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -152,6 +152,11 @@ func main() { // Storage module - provides R2 storage integration for images storageDeps := storage.ProvideStorageDependencies(appRouter) + // Set storage port for contest thumbnail upload + if storageDeps != nil { + contestDeps.ContestService.SetStoragePort(storageDeps.StoragePort) + } + // Banner module - provides main banner management for homepage bannerDeps := banner.ProvideBannerDependencies(db, appRouter) diff --git a/internal/contest/application/contest_service.go b/internal/contest/application/contest_service.go index 963e4fd..e6eb9e6 100644 --- a/internal/contest/application/contest_service.go +++ b/internal/contest/application/contest_service.go @@ -10,10 +10,15 @@ import ( commonDto "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/dto" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/exception" oauth2Port "github.com/FOR-GAMERS/GAMERS-BE/internal/oauth2/application/port" + storageDomain "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/domain" "context" "errors" + "fmt" "log" + "mime/multipart" "time" + + "github.com/google/uuid" ) // TournamentGeneratorPort defines the interface for tournament generation @@ -32,6 +37,7 @@ type ContestService struct { tournamentGenerator TournamentGeneratorPort teamDBPort gamePort.TeamDatabasePort gameTeamDBPort gamePort.GameTeamDatabasePort + storagePort port.ContestStoragePort } func NewContestService( @@ -94,6 +100,11 @@ func NewContestServiceFull( } } +// SetStoragePort sets the storage port for file upload operations +func (c *ContestService) SetStoragePort(storagePort port.ContestStoragePort) { + c.storagePort = storagePort +} + func (c *ContestService) SaveContest(req *dto.CreateContestRequest, userId int64) (*domain.Contest, *dto.DiscordLinkRequiredResponse, error) { // Check if user has linked Discord account discordAccount, err := c.oauth2Repository.FindDiscordAccountByUserId(userId) @@ -470,3 +481,68 @@ func (c *ContestService) GetDiscordTextChannels(guildID string) ([]port.DiscordC } return c.discordValidator.GetGuildTextChannels(guildID) } + +// UploadThumbnail uploads a thumbnail image for a contest and updates the contest record +func (c *ContestService) UploadThumbnail(ctx context.Context, contestId, userId int64, file *multipart.FileHeader) (*dto.ThumbnailUploadResponse, error) { + if c.storagePort == nil { + return nil, exception.ErrStorageUploadFailed + } + + // Check leader permission + if err := c.checkLeaderPermission(contestId, userId); err != nil { + return nil, err + } + + // Validate file + if err := storageDomain.ValidateFile(file, storageDomain.UploadTypeContestThumbnail); err != nil { + return nil, err + } + + // Generate storage key + mimeType := file.Header.Get("Content-Type") + ext := storageDomain.GetExtensionFromMimeType(mimeType) + key := fmt.Sprintf("%s/%d/%s%s", storageDomain.UploadTypeContestThumbnail, contestId, uuid.New().String(), ext) + + // Upload file to storage + src, err := file.Open() + if err != nil { + return nil, exception.ErrStorageUploadFailed + } + defer src.Close() + + if err := c.storagePort.Upload(ctx, key, src, file.Size, mimeType); err != nil { + return nil, exception.ErrStorageUploadFailed + } + + // Get public URL and update contest record + url := c.storagePort.GetPublicURL(key) + + contest, err := c.repository.GetContestById(contestId) + if err != nil { + return nil, err + } + + // Delete old thumbnail from storage if exists + if contest.BannerKey != nil && *contest.BannerKey != "" { + go func() { + if delErr := c.storagePort.Delete(context.Background(), *contest.BannerKey); delErr != nil { + log.Printf("[UploadThumbnail] Failed to delete old thumbnail %s: %v", *contest.BannerKey, delErr) + } + }() + } + + contest.Thumbnail = &url + contest.BannerKey = &key + + if err := c.repository.UpdateContest(contest); err != nil { + return nil, err + } + + return &dto.ThumbnailUploadResponse{ + Key: key, + URL: url, + Size: file.Size, + MimeType: mimeType, + UploadedAt: time.Now(), + }, nil +} diff --git a/internal/contest/application/dto/contest_dto.go b/internal/contest/application/dto/contest_dto.go index 2d017a7..f9d73c7 100644 --- a/internal/contest/application/dto/contest_dto.go +++ b/internal/contest/application/dto/contest_dto.go @@ -269,6 +269,15 @@ func ToMyContestResponses(contests []*port.ContestWithMembership) []*MyContestRe return responses } +// ThumbnailUploadResponse represents the response after uploading a contest thumbnail +type ThumbnailUploadResponse struct { + Key string `json:"key"` + URL string `json:"url"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + UploadedAt time.Time `json:"uploaded_at"` +} + // ChangeMemberRoleRequest represents the request to change a member's role type ChangeMemberRoleRequest struct { MemberType domain.MemberType `json:"member_type" binding:"required"` diff --git a/internal/contest/application/port/contest_storage_port.go b/internal/contest/application/port/contest_storage_port.go new file mode 100644 index 0000000..02c7e69 --- /dev/null +++ b/internal/contest/application/port/contest_storage_port.go @@ -0,0 +1,12 @@ +package port + +import ( + "context" + "io" +) + +type ContestStoragePort interface { + Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) error + Delete(ctx context.Context, key string) error + GetPublicURL(key string) string +} diff --git a/internal/contest/presentation/contest_controller.go b/internal/contest/presentation/contest_controller.go index e8c6ab5..f886af3 100644 --- a/internal/contest/presentation/contest_controller.go +++ b/internal/contest/presentation/contest_controller.go @@ -37,6 +37,7 @@ func (c *ContestController) RegisterRoute() { privateGroup.GET("/me", c.GetMyContests) privateGroup.PATCH("/:id", c.UpdateContest) privateGroup.DELETE("/:id", c.DeleteContest) + privateGroup.POST("/:id/thumbnail", c.UploadThumbnail) privateGroup.POST("/:id/start", c.StartContest) privateGroup.POST("/:id/stop", c.StopContest) @@ -263,6 +264,43 @@ func (c *ContestController) StopContest(ctx *gin.Context) { c.helper.RespondOK(ctx, contest, err, "contest stopped successfully") } +// UploadThumbnail godoc +// @Summary Upload a contest thumbnail image +// @Description Upload a thumbnail image for a contest. Maximum file size is 5MB. Allowed formats: jpeg, png, webp. Only the contest leader can upload. +// @Tags contests +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param id path int true "Contest ID" +// @Param file formData file true "Image file (max 5MB, jpeg/png/webp)" +// @Success 201 {object} response.Response{data=dto.ThumbnailUploadResponse} +// @Failure 400 {object} response.Response +// @Failure 401 {object} response.Response +// @Failure 403 {object} response.Response +// @Router /api/contests/{id}/thumbnail [post] +func (c *ContestController) UploadThumbnail(ctx *gin.Context) { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil { + response.JSON(ctx, response.BadRequest("invalid contest id")) + return + } + + userId, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return + } + + file, err := ctx.FormFile("file") + if err != nil { + response.JSON(ctx, response.BadRequest("file is required")) + return + } + + result, err := c.service.UploadThumbnail(ctx.Request.Context(), id, userId, file) + c.helper.RespondCreated(ctx, result, err, "thumbnail uploaded successfully") +} + // GetMyContests godoc // @Summary Get contests I have joined // @Description Get all contests that the authenticated user has joined with pagination, sorting, and filtering support @@ -421,3 +459,26 @@ func HandleStopContest(ctx *gin.Context, service *application.ContestService, he contest, err := service.StopContest(ctx.Request.Context(), id, userId) helper.RespondOK(ctx, contest, err, "contest stopped successfully") } + +func HandleUploadThumbnail(ctx *gin.Context, service *application.ContestService, helper *handler.ControllerHelper) { + id, err := strconv.ParseInt(ctx.Param("id"), 10, 64) + if err != nil { + response.JSON(ctx, response.BadRequest("invalid contest id")) + return + } + + userId, ok := middleware.GetUserIdFromContext(ctx) + if !ok { + response.JSON(ctx, response.Error(401, "user not authenticated")) + return + } + + file, err := ctx.FormFile("file") + if err != nil { + response.JSON(ctx, response.BadRequest("file is required")) + return + } + + result, err := service.UploadThumbnail(ctx.Request.Context(), id, userId, file) + helper.RespondCreated(ctx, result, err, "thumbnail uploaded successfully") +} diff --git a/internal/storage/domain/storage.go b/internal/storage/domain/storage.go index 7a4c569..7dbb577 100644 --- a/internal/storage/domain/storage.go +++ b/internal/storage/domain/storage.go @@ -11,15 +11,17 @@ import ( type UploadType string const ( - UploadTypeContestBanner UploadType = "contest-banners" - UploadTypeUserProfile UploadType = "user-profiles" - UploadTypeMainBanner UploadType = "main-banners" + UploadTypeContestBanner UploadType = "contest-banners" + UploadTypeContestThumbnail UploadType = "contest-thumbnails" + UploadTypeUserProfile UploadType = "user-profiles" + UploadTypeMainBanner UploadType = "main-banners" ) const ( - MaxContestBannerSize = 5 * 1024 * 1024 // 5MB - MaxUserProfileSize = 2 * 1024 * 1024 // 2MB - MaxMainBannerSize = 5 * 1024 * 1024 // 5MB + MaxContestBannerSize = 5 * 1024 * 1024 // 5MB + MaxContestThumbnailSize = 5 * 1024 * 1024 // 5MB + MaxUserProfileSize = 2 * 1024 * 1024 // 2MB + MaxMainBannerSize = 5 * 1024 * 1024 // 5MB ) var AllowedMimeTypes = map[string]bool{ @@ -74,6 +76,8 @@ func getMaxSize(uploadType UploadType) int64 { switch uploadType { case UploadTypeContestBanner: return MaxContestBannerSize + case UploadTypeContestThumbnail: + return MaxContestThumbnailSize case UploadTypeUserProfile: return MaxUserProfileSize case UploadTypeMainBanner: diff --git a/internal/storage/provider.go b/internal/storage/provider.go index e153fdf..d9743c4 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -4,6 +4,7 @@ import ( "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/handler" "github.com/FOR-GAMERS/GAMERS-BE/internal/global/common/router" "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/application" + "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/application/port" "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/infra" "github.com/FOR-GAMERS/GAMERS-BE/internal/storage/presentation" "log" @@ -12,6 +13,7 @@ import ( type Dependencies struct { Controller *presentation.StorageController StorageService *application.StorageService + StoragePort port.StoragePort } func ProvideStorageDependencies(router *router.Router) *Dependencies { @@ -31,5 +33,6 @@ func ProvideStorageDependencies(router *router.Router) *Dependencies { return &Dependencies{ Controller: storageController, StorageService: storageService, + StoragePort: storageAdapter, } }