diff --git a/internal/app/app.go b/internal/app/app.go index 68a41d4..a9eb6ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -177,7 +177,6 @@ type repositories struct { chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl - customerPointsRepo *repository.CustomerPointsRepository customerTokensRepo *repository.CustomerTokensRepository tierRepo *repository.TierRepository gameRepo *repository.GameRepository @@ -222,7 +221,6 @@ func (a *App) initRepositories() *repositories { chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), - customerPointsRepo: repository.NewCustomerPointsRepository(a.db), customerTokensRepo: repository.NewCustomerTokensRepository(a.db), tierRepo: repository.NewTierRepository(a.db), gameRepo: repository.NewGameRepository(a.db), @@ -263,7 +261,6 @@ type processors struct { chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl - customerPointsProcessor *processor.CustomerPointsProcessor customerTokensProcessor *processor.CustomerTokensProcessor tierProcessor *processor.TierProcessor gameProcessor *processor.GameProcessor @@ -310,7 +307,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), - customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo), customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo), tierProcessor: processor.NewTierProcessor(repos.tierRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo), @@ -320,7 +316,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, diff --git a/internal/contract/customer_game_contract.go b/internal/contract/customer_game_contract.go new file mode 100644 index 0000000..a074fb8 --- /dev/null +++ b/internal/contract/customer_game_contract.go @@ -0,0 +1,60 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Contracts +type GetCustomerGamesRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +// Response Contracts +type GetCustomerGamesResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerGamesResponseData `json:"data,omitempty"` +} + +type GetCustomerGamesResponseData struct { + Games []CustomerGameResponse `json:"games"` +} + +type CustomerGameResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsActive bool `json:"is_active"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CustomerGamePrizeResponse struct { + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + Name string `json:"name"` + Image *string `json:"image,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Ferris Wheel Game Contracts +type GetFerrisWheelGameRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetFerrisWheelGameResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` +} + +type GetFerrisWheelGameResponseData struct { + Game CustomerGameResponse `json:"game"` + Prizes []CustomerGamePrizeResponse `json:"prizes"` +} diff --git a/internal/contract/game_prize_contract.go b/internal/contract/game_prize_contract.go index 69c9ae1..e793b80 100644 --- a/internal/contract/game_prize_contract.go +++ b/internal/contract/game_prize_contract.go @@ -14,6 +14,7 @@ type CreateGamePrizeRequest struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty" validate:"omitempty,max=500"` Metadata map[string]interface{} `json:"metadata"` } @@ -24,6 +25,7 @@ type UpdateGamePrizeRequest struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty" validate:"omitempty,max=500"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -36,6 +38,7 @@ type GamePrizeResponse struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty"` Metadata map[string]interface{} `json:"metadata"` Game *GameResponse `json:"game,omitempty"` FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` diff --git a/internal/entities/game_prize.go b/internal/entities/game_prize.go index b277c6b..8345f38 100644 --- a/internal/entities/game_prize.go +++ b/internal/entities/game_prize.go @@ -16,6 +16,7 @@ type GamePrize struct { MaxStock *int `gorm:"" json:"max_stock,omitempty"` Threshold *int64 `gorm:"" json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,omitempty"` + Image *string `gorm:"type:varchar(500)" json:"image,omitempty"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/internal/entities/otp_session.go b/internal/entities/otp_session.go index 0b68f50..175c453 100644 --- a/internal/entities/otp_session.go +++ b/internal/entities/otp_session.go @@ -17,6 +17,7 @@ type OtpSession struct { IsUsed bool `gorm:"default:false;index" json:"is_used"` AttemptsCount int `gorm:"default:0" json:"attempts_count"` MaxAttempts int `gorm:"default:3" json:"max_attempts"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 03b084f..8e98a26 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -121,3 +121,33 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet") } + +func (h *CustomerPointsHandler) GetCustomerGames(c *gin.Context) { + ctx := c.Request.Context() + + response, err := h.customerPointsService.GetCustomerGames(ctx) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerGames -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerGames") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerGames") +} + +func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { + ctx := c.Request.Context() + + response, err := h.customerPointsService.GetFerrisWheelGame(ctx) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetFerrisWheelGame -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetFerrisWheelGame") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") +} diff --git a/internal/mappers/game_prize_mapper.go b/internal/mappers/game_prize_mapper.go index 60c0e23..bf09fc7 100644 --- a/internal/mappers/game_prize_mapper.go +++ b/internal/mappers/game_prize_mapper.go @@ -20,6 +20,7 @@ func ToGamePrizeResponse(gamePrize *entities.GamePrize) *models.GamePrizeRespons MaxStock: gamePrize.MaxStock, Threshold: gamePrize.Threshold, FallbackPrizeID: gamePrize.FallbackPrizeID, + Image: gamePrize.Image, Metadata: gamePrize.Metadata, Game: ToGameResponse(&gamePrize.Game), FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize), @@ -47,6 +48,7 @@ func ToGamePrizeEntity(req *models.CreateGamePrizeRequest) *entities.GamePrize { MaxStock: req.MaxStock, Threshold: req.Threshold, FallbackPrizeID: req.FallbackPrizeID, + Image: req.Image, Metadata: req.Metadata, } } @@ -71,6 +73,9 @@ func UpdateGamePrizeEntity(gamePrize *entities.GamePrize, req *models.UpdateGame if req.FallbackPrizeID != nil { gamePrize.FallbackPrizeID = req.FallbackPrizeID } + if req.Image != nil { + gamePrize.Image = req.Image + } if req.Metadata != nil { gamePrize.Metadata = req.Metadata } diff --git a/internal/models/customer_game.go b/internal/models/customer_game.go new file mode 100644 index 0000000..92a2a12 --- /dev/null +++ b/internal/models/customer_game.go @@ -0,0 +1,60 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Models +type GetCustomerGamesRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +// Response Models +type GetCustomerGamesResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerGamesResponseData `json:"data,omitempty"` +} + +type GetCustomerGamesResponseData struct { + Games []CustomerGameResponse `json:"games"` +} + +type CustomerGameResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IsActive bool `json:"is_active"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CustomerGamePrizeResponse struct { + ID uuid.UUID `json:"id"` + GameID uuid.UUID `json:"game_id"` + Name string `json:"name"` + Image *string `json:"image,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Ferris Wheel Game Models +type GetFerrisWheelGameRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetFerrisWheelGameResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` +} + +type GetFerrisWheelGameResponseData struct { + Game CustomerGameResponse `json:"game"` + Prizes []CustomerGamePrizeResponse `json:"prizes"` +} diff --git a/internal/models/game_prize.go b/internal/models/game_prize.go index 6b99a24..2616cf1 100644 --- a/internal/models/game_prize.go +++ b/internal/models/game_prize.go @@ -14,6 +14,7 @@ type CreateGamePrizeRequest struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty" validate:"omitempty,max=500"` Metadata map[string]interface{} `json:"metadata"` } @@ -24,6 +25,7 @@ type UpdateGamePrizeRequest struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty" validate:"omitempty,max=500"` Metadata map[string]interface{} `json:"metadata,omitempty"` } @@ -36,6 +38,7 @@ type GamePrizeResponse struct { MaxStock *int `json:"max_stock,omitempty"` Threshold *int64 `json:"threshold,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` + Image *string `json:"image,omitempty"` Metadata map[string]interface{} `json:"metadata"` Game *GameResponse `json:"game,omitempty"` FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` diff --git a/internal/processor/customer_auth_processor.go b/internal/processor/customer_auth_processor.go index 402828a..b2c15b6 100644 --- a/internal/processor/customer_auth_processor.go +++ b/internal/processor/customer_auth_processor.go @@ -25,24 +25,20 @@ type CustomerAuthProcessor interface { } type customerAuthProcessor struct { - customerAuthRepo repository.CustomerAuthRepository - otpProcessor OtpProcessor - otpRepo repository.OtpRepository - jwtSecret string - tokenTTLMinutes int - otpStorage map[string]*models.OtpSession // In-memory storage for OTP sessions - registrationStorage map[string]*models.RegistrationSession // In-memory storage for registration sessions + customerAuthRepo repository.CustomerAuthRepository + otpProcessor OtpProcessor + otpRepo repository.OtpRepository + jwtSecret string + tokenTTLMinutes int } func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor { return &customerAuthProcessor{ - customerAuthRepo: customerAuthRepo, - otpProcessor: otpProcessor, - otpRepo: otpRepo, - jwtSecret: jwtSecret, - tokenTTLMinutes: tokenTTLMinutes, - otpStorage: make(map[string]*models.OtpSession), - registrationStorage: make(map[string]*models.RegistrationSession), + customerAuthRepo: customerAuthRepo, + otpProcessor: otpProcessor, + otpRepo: otpRepo, + jwtSecret: jwtSecret, + tokenTTLMinutes: tokenTTLMinutes, } } @@ -155,17 +151,19 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont return nil, fmt.Errorf("failed to create OTP session: %w", err) } - // Store registration session - registrationSession := &models.RegistrationSession{ - Token: registrationToken, - PhoneNumber: req.PhoneNumber, - Name: req.Name, - BirthDate: req.BirthDate, - ExpiresAt: time.Now().Add(10 * time.Minute), - Step: "otp_sent", + // Store registration data in OTP session metadata + registrationData := map[string]interface{}{ + "registration_token": registrationToken, + "name": req.Name, + "birth_date": req.BirthDate, + "step": "otp_sent", } - p.registrationStorage[registrationToken] = registrationSession + // Update OTP session with registration metadata + otpSession.Metadata = registrationData + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { + return nil, fmt.Errorf("failed to update OTP session with registration data: %w", err) + } // Send OTP via WhatsApp if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "registration"); err != nil { @@ -184,18 +182,19 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont } func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) { - // Get registration session - registrationSession, exists := p.registrationStorage[req.RegistrationToken] - if !exists { + otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken) + if err != nil { + return nil, fmt.Errorf("failed to get OTP session: %w", err) + } + + if otpSession == nil { return nil, fmt.Errorf("invalid or expired registration token") } - if time.Now().After(registrationSession.ExpiresAt) { - delete(p.registrationStorage, req.RegistrationToken) + if otpSession.IsExpired() { return nil, fmt.Errorf("registration token expired") } - // Validate OTP format if !p.otpProcessor.ValidateOtpCode(req.OtpCode) { return &models.RegisterVerifyOtpResponse{ Status: "FAILED", @@ -203,34 +202,33 @@ func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.Reg }, nil } - // Get the OTP session for this phone number and purpose - otpSession, err := p.otpRepo.GetOtpSessionByPhoneAndPurpose(ctx, registrationSession.PhoneNumber, "registration") - if err != nil { - return &models.RegisterVerifyOtpResponse{ - Status: "FAILED", - Message: "Failed to validate OTP session.", - }, nil - } - - if otpSession == nil { - return &models.RegisterVerifyOtpResponse{ - Status: "FAILED", - Message: "No active OTP session found.", - }, nil - } - - // Verify OTP code and mark as used if otpSession.Code != req.OtpCode { otpSession.IncrementAttempts() - p.otpRepo.UpdateOtpSession(ctx, otpSession) + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { + fmt.Printf("Warning: failed to update OTP session attempts: %v\n", err) + } return &models.RegisterVerifyOtpResponse{ Status: "FAILED", Message: "Invalid OTP code.", }, nil } + if otpSession.IsUsed || otpSession.IsMaxAttemptsReached() { + return &models.RegisterVerifyOtpResponse{ + Status: "FAILED", + Message: "OTP code already used or max attempts reached.", + }, nil + } + // Mark OTP as used otpSession.MarkAsUsed() + + // Update registration step in metadata + if otpSession.Metadata == nil { + otpSession.Metadata = make(map[string]interface{}) + } + otpSession.Metadata["step"] = "otp_verified" + if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil { return &models.RegisterVerifyOtpResponse{ Status: "FAILED", @@ -238,10 +236,6 @@ func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.Reg }, nil } - // Update registration session - registrationSession.Step = "otp_verified" - p.registrationStorage[req.RegistrationToken] = registrationSession - return &models.RegisterVerifyOtpResponse{ Status: "OTP_VERIFIED", Message: "OTP verified, continue to set password.", @@ -252,23 +246,26 @@ func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.Reg } func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) { - // Validate passwords match if req.Password != req.ConfirmPassword { return nil, fmt.Errorf("passwords do not match") } - // Get registration session - registrationSession, exists := p.registrationStorage[req.RegistrationToken] - if !exists { + // Get OTP session by registration token from metadata + otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken) + if err != nil { + return nil, fmt.Errorf("failed to get OTP session: %w", err) + } + + if otpSession == nil { return nil, fmt.Errorf("invalid or expired registration token") } - if time.Now().After(registrationSession.ExpiresAt) { - delete(p.registrationStorage, req.RegistrationToken) + if otpSession.IsExpired() { return nil, fmt.Errorf("registration token expired") } - if registrationSession.Step != "otp_verified" { + step, ok := otpSession.Metadata["step"].(string) + if !ok || step != "otp_verified" { return nil, fmt.Errorf("OTP verification required before setting password") } @@ -280,31 +277,38 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R passwordHashStr := string(passwordHash) + // Extract registration data from OTP session metadata + name, ok := otpSession.Metadata["name"].(string) + if !ok { + return nil, fmt.Errorf("invalid registration data: name not found") + } + + birthDateStr, ok := otpSession.Metadata["birth_date"].(string) + if !ok { + return nil, fmt.Errorf("invalid registration data: birth_date not found") + } + // Parse birth date - birthDate, err := time.Parse("2006-01-02", registrationSession.BirthDate) + birthDate, err := time.Parse("2006-01-02", birthDateStr) if err != nil { return nil, fmt.Errorf("invalid birth date format: %w", err) } - // Create customer + defaultOrgID := uuid.MustParse("87bec7c1-e274-4f66-bac5-84e632208470") // This should be configurable + customer := &entities.Customer{ - Name: registrationSession.Name, - PhoneNumber: ®istrationSession.PhoneNumber, - BirthDate: &birthDate, - PasswordHash: &passwordHashStr, - IsActive: true, - // Note: OrganizationID should be set based on your business logic - // For now, we'll use a default organization or require it in the request + OrganizationID: defaultOrgID, + Name: name, + PhoneNumber: &otpSession.PhoneNumber, + BirthDate: &birthDate, + PasswordHash: &passwordHashStr, + IsActive: true, } if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil { return nil, fmt.Errorf("failed to create customer: %w", err) } - // Clean up registration session - delete(p.registrationStorage, req.RegistrationToken) - - // Generate JWT tokens using customer JWT util accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) @@ -320,7 +324,7 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R ID: customer.ID, Name: customer.Name, PhoneNumber: *customer.PhoneNumber, - BirthDate: registrationSession.BirthDate, + BirthDate: birthDate.Format("2006-01-02"), }, }, }, nil diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index 4155f05..6934645 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -13,11 +13,13 @@ import ( type CustomerPointsProcessor struct { customerPointsRepo repository.CustomerPointsRepository + gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor { return &CustomerPointsProcessor{ customerPointsRepo: customerPointsRepo, + gameRepo: gameRepo, } } @@ -220,3 +222,92 @@ func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, cust }, }, nil } + +// GetCustomerGamesAPI gets active SPIN games for customers +func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { + // Get active SPIN games + games, err := p.gameRepo.GetActiveSpinGames(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get active SPIN games: %w", err) + } + + // Convert to response format + var gameResponses []models.CustomerGameResponse + for _, game := range games { + var prizeResponses []models.CustomerGamePrizeResponse + for _, prize := range game.Prizes { + prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ + ID: prize.ID, + GameID: prize.GameID, + Name: prize.Name, + Image: prize.Image, + Metadata: (*map[string]interface{})(&prize.Metadata), + CreatedAt: prize.CreatedAt, + UpdatedAt: prize.UpdatedAt, + }) + } + + gameResponses = append(gameResponses, models.CustomerGameResponse{ + ID: game.ID, + Name: game.Name, + Type: string(game.Type), + IsActive: game.IsActive, + Metadata: (*map[string]interface{})(&game.Metadata), + Prizes: prizeResponses, + CreatedAt: game.CreatedAt, + UpdatedAt: game.UpdatedAt, + }) + } + + return &models.GetCustomerGamesResponse{ + Status: "SUCCESS", + Message: "Customer games retrieved successfully.", + Data: &models.GetCustomerGamesResponseData{ + Games: gameResponses, + }, + }, nil +} + +// GetFerrisWheelGameAPI gets the Ferris Wheel game for customers +func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { + // Get Ferris Wheel game + game, err := p.gameRepo.GetFerrisWheelGame(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) + } + + // Convert prizes to response format + var prizeResponses []models.CustomerGamePrizeResponse + for _, prize := range game.Prizes { + prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ + ID: prize.ID, + GameID: prize.GameID, + Name: prize.Name, + Image: prize.Image, + Metadata: (*map[string]interface{})(&prize.Metadata), + CreatedAt: prize.CreatedAt, + UpdatedAt: prize.UpdatedAt, + }) + } + + // Convert game to response format + gameResponse := models.CustomerGameResponse{ + ID: game.ID, + Name: game.Name, + Type: string(game.Type), + IsActive: game.IsActive, + Metadata: (*map[string]interface{})(&game.Metadata), + Prizes: prizeResponses, + CreatedAt: game.CreatedAt, + UpdatedAt: game.UpdatedAt, + } + + return &models.GetFerrisWheelGameResponse{ + Status: "SUCCESS", + Message: "Ferris Wheel game retrieved successfully.", + Data: &models.GetFerrisWheelGameResponseData{ + Game: gameResponse, + Prizes: prizeResponses, + }, + }, nil +} diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go index 194e8b9..25a4cd0 100644 --- a/internal/processor/game_play_processor.go +++ b/internal/processor/game_play_processor.go @@ -19,7 +19,7 @@ type GamePlayProcessor struct { gameRepo *repository.GameRepository gamePrizeRepo *repository.GamePrizeRepository customerTokensRepo *repository.CustomerTokensRepository - customerPointsRepo *repository.CustomerPointsRepository + customerPointsRepo repository.CustomerPointsRepository } func NewGamePlayProcessor( @@ -27,7 +27,7 @@ func NewGamePlayProcessor( gameRepo *repository.GameRepository, gamePrizeRepo *repository.GamePrizeRepository, customerTokensRepo *repository.CustomerTokensRepository, - customerPointsRepo *repository.CustomerPointsRepository, + customerPointsRepo repository.CustomerPointsRepository, ) *GamePlayProcessor { return &GamePlayProcessor{ gamePlayRepo: gamePlayRepo, diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go index 2db5a98..7affbeb 100644 --- a/internal/repository/game_repository.go +++ b/internal/repository/game_repository.go @@ -86,3 +86,27 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e } return games, nil } + +func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { + var games []entities.Game + err := r.db.WithContext(ctx). + Preload("Prizes"). + Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). + Find(&games).Error + if err != nil { + return nil, err + } + return games, nil +} + +func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { + var game entities.Game + err := r.db.WithContext(ctx). + Preload("Prizes"). + Where("is_active = ? AND LOWER(name) LIKE ?", true, "%ferris wheel%"). + First(&game).Error + if err != nil { + return nil, err + } + return &game, nil +} diff --git a/internal/repository/otp_repository.go b/internal/repository/otp_repository.go index f8f7b12..5d0bd8c 100644 --- a/internal/repository/otp_repository.go +++ b/internal/repository/otp_repository.go @@ -15,6 +15,7 @@ type OtpRepository interface { GetOtpSessionByToken(ctx context.Context, token string) (*entities.OtpSession, error) GetOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) GetLastOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error) + GetOtpSessionByRegistrationToken(ctx context.Context, registrationToken string) (*entities.OtpSession, error) UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error DeleteOtpSession(ctx context.Context, token string) error DeleteExpiredOtpSessions(ctx context.Context) error @@ -73,6 +74,18 @@ func (r *otpRepository) GetLastOtpSessionByPhoneAndPurpose(ctx context.Context, return &otpSession, nil } +func (r *otpRepository) GetOtpSessionByRegistrationToken(ctx context.Context, registrationToken string) (*entities.OtpSession, error) { + var otpSession entities.OtpSession + if err := r.db.WithContext(ctx).Where("metadata->>'registration_token' = ? AND purpose = ?", + registrationToken, "registration").First(&otpSession).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil // OTP session not found + } + return nil, fmt.Errorf("failed to get OTP session by registration token: %w", err) + } + return &otpSession, nil +} + func (r *otpRepository) UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error { if err := r.db.WithContext(ctx).Save(otpSession).Error; err != nil { return fmt.Errorf("failed to update OTP session: %w", err) diff --git a/internal/router/router.go b/internal/router/router.go index 7a3e5c9..b548ff1 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -194,6 +194,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) + customer.GET("/games", r.customerPointsHandler.GetCustomerGames) + customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) } organizations := v1.Group("/organizations") diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 48aa914..20b473d 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -12,6 +12,8 @@ type CustomerPointsService interface { GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) + GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) + GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } type customerPointsService struct { @@ -62,3 +64,21 @@ func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerI return response, nil } + +func (s *customerPointsService) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) { + response, err := s.customerPointsProcessor.GetCustomerGamesAPI(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get customer games: %w", err) + } + + return response, nil +} + +func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { + response, err := s.customerPointsProcessor.GetFerrisWheelGameAPI(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) + } + + return response, nil +} diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index 4b2d035..b7a2b70 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -49,7 +49,11 @@ func CustomerPointsModelToResponse(model *models.CustomerPointsResponse) *contra } } -func PaginatedCustomerPointsResponseToContract(model *models.PaginatedResponse[models.CustomerPointsResponse]) *contract.PaginatedCustomerPointsResponse { +func PaginatedCustomerPointsResponseToContract(model *models.PaginatedCustomerPointsResponse) *contract.PaginatedCustomerPointsResponse { + if model == nil { + return nil + } + responses := make([]contract.CustomerPointsResponse, len(model.Data)) for i, item := range model.Data { responses[i] = *CustomerPointsModelToResponse(&item) @@ -57,10 +61,10 @@ func PaginatedCustomerPointsResponseToContract(model *models.PaginatedResponse[m return &contract.PaginatedCustomerPointsResponse{ Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, + TotalCount: model.TotalCount, + Page: model.Page, + Limit: model.Limit, + TotalPages: model.TotalPages, } } diff --git a/migrations/000059_add_image_to_game_prizes.down.sql b/migrations/000059_add_image_to_game_prizes.down.sql new file mode 100644 index 0000000..eecf819 --- /dev/null +++ b/migrations/000059_add_image_to_game_prizes.down.sql @@ -0,0 +1,3 @@ +-- Remove image field from game_prizes table +ALTER TABLE game_prizes +DROP COLUMN image; diff --git a/migrations/000059_add_image_to_game_prizes.up.sql b/migrations/000059_add_image_to_game_prizes.up.sql new file mode 100644 index 0000000..ae06def --- /dev/null +++ b/migrations/000059_add_image_to_game_prizes.up.sql @@ -0,0 +1,6 @@ +-- Add image field to game_prizes table +ALTER TABLE game_prizes +ADD COLUMN image VARCHAR(500); + +-- Add comment for the new column +COMMENT ON COLUMN game_prizes.image IS 'URL or path to the prize image'; diff --git a/migrations/000060_add_metadata_to_otp_sessions.down.sql b/migrations/000060_add_metadata_to_otp_sessions.down.sql new file mode 100644 index 0000000..dab6524 --- /dev/null +++ b/migrations/000060_add_metadata_to_otp_sessions.down.sql @@ -0,0 +1,3 @@ +-- Remove metadata field from otp_sessions table +ALTER TABLE otp_sessions +DROP COLUMN metadata; diff --git a/migrations/000060_add_metadata_to_otp_sessions.up.sql b/migrations/000060_add_metadata_to_otp_sessions.up.sql new file mode 100644 index 0000000..1d525ad --- /dev/null +++ b/migrations/000060_add_metadata_to_otp_sessions.up.sql @@ -0,0 +1,6 @@ +-- Add metadata field to otp_sessions table +ALTER TABLE otp_sessions +ADD COLUMN metadata JSONB DEFAULT '{}'; + +-- Add comment for the new column +COMMENT ON COLUMN otp_sessions.metadata IS 'Additional data stored as JSON for the OTP session';