test wheels

This commit is contained in:
Aditya Siregar 2025-09-18 12:01:20 +07:00
parent f64fec1fe2
commit be92ec8b23
21 changed files with 420 additions and 85 deletions

View File

@ -177,7 +177,6 @@ type repositories struct {
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
accountRepo *repository.AccountRepositoryImpl accountRepo *repository.AccountRepositoryImpl
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
customerPointsRepo *repository.CustomerPointsRepository
customerTokensRepo *repository.CustomerTokensRepository customerTokensRepo *repository.CustomerTokensRepository
tierRepo *repository.TierRepository tierRepo *repository.TierRepository
gameRepo *repository.GameRepository gameRepo *repository.GameRepository
@ -222,7 +221,6 @@ func (a *App) initRepositories() *repositories {
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
accountRepo: repository.NewAccountRepositoryImpl(a.db), accountRepo: repository.NewAccountRepositoryImpl(a.db),
orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl),
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
customerTokensRepo: repository.NewCustomerTokensRepository(a.db), customerTokensRepo: repository.NewCustomerTokensRepository(a.db),
tierRepo: repository.NewTierRepository(a.db), tierRepo: repository.NewTierRepository(a.db),
gameRepo: repository.NewGameRepository(a.db), gameRepo: repository.NewGameRepository(a.db),
@ -263,7 +261,6 @@ type processors struct {
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
accountProcessor *processor.AccountProcessorImpl accountProcessor *processor.AccountProcessorImpl
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
customerPointsProcessor *processor.CustomerPointsProcessor
customerTokensProcessor *processor.CustomerTokensProcessor customerTokensProcessor *processor.CustomerTokensProcessor
tierProcessor *processor.TierProcessor tierProcessor *processor.TierProcessor
gameProcessor *processor.GameProcessor gameProcessor *processor.GameProcessor
@ -310,7 +307,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo),
orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl),
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo),
customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo), customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo),
tierProcessor: processor.NewTierProcessor(repos.tierRepo), tierProcessor: processor.NewTierProcessor(repos.tierRepo),
gameProcessor: processor.NewGameProcessor(repos.gameRepo), gameProcessor: processor.NewGameProcessor(repos.gameRepo),
@ -320,7 +316,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), 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, otpProcessor: otpProcessor,
fileClient: fileClient, fileClient: fileClient,
inventoryMovementService: inventoryMovementService, inventoryMovementService: inventoryMovementService,

View File

@ -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"`
}

View File

@ -14,6 +14,7 @@ type CreateGamePrizeRequest struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
} }
@ -24,6 +25,7 @@ type UpdateGamePrizeRequest struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,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"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
@ -36,6 +38,7 @@ type GamePrizeResponse struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
Image *string `json:"image,omitempty"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
Game *GameResponse `json:"game,omitempty"` Game *GameResponse `json:"game,omitempty"`
FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"`

View File

@ -16,6 +16,7 @@ type GamePrize struct {
MaxStock *int `gorm:"" json:"max_stock,omitempty"` MaxStock *int `gorm:"" json:"max_stock,omitempty"`
Threshold *int64 `gorm:"" json:"threshold,omitempty"` Threshold *int64 `gorm:"" json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,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"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`

View File

@ -17,6 +17,7 @@ type OtpSession struct {
IsUsed bool `gorm:"default:false;index" json:"is_used"` IsUsed bool `gorm:"default:false;index" json:"is_used"`
AttemptsCount int `gorm:"default:0" json:"attempts_count"` AttemptsCount int `gorm:"default:0" json:"attempts_count"`
MaxAttempts int `gorm:"default:3" json:"max_attempts"` MaxAttempts int `gorm:"default:3" json:"max_attempts"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
} }

View File

@ -121,3 +121,33 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet") 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")
}

View File

@ -20,6 +20,7 @@ func ToGamePrizeResponse(gamePrize *entities.GamePrize) *models.GamePrizeRespons
MaxStock: gamePrize.MaxStock, MaxStock: gamePrize.MaxStock,
Threshold: gamePrize.Threshold, Threshold: gamePrize.Threshold,
FallbackPrizeID: gamePrize.FallbackPrizeID, FallbackPrizeID: gamePrize.FallbackPrizeID,
Image: gamePrize.Image,
Metadata: gamePrize.Metadata, Metadata: gamePrize.Metadata,
Game: ToGameResponse(&gamePrize.Game), Game: ToGameResponse(&gamePrize.Game),
FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize), FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize),
@ -47,6 +48,7 @@ func ToGamePrizeEntity(req *models.CreateGamePrizeRequest) *entities.GamePrize {
MaxStock: req.MaxStock, MaxStock: req.MaxStock,
Threshold: req.Threshold, Threshold: req.Threshold,
FallbackPrizeID: req.FallbackPrizeID, FallbackPrizeID: req.FallbackPrizeID,
Image: req.Image,
Metadata: req.Metadata, Metadata: req.Metadata,
} }
} }
@ -71,6 +73,9 @@ func UpdateGamePrizeEntity(gamePrize *entities.GamePrize, req *models.UpdateGame
if req.FallbackPrizeID != nil { if req.FallbackPrizeID != nil {
gamePrize.FallbackPrizeID = req.FallbackPrizeID gamePrize.FallbackPrizeID = req.FallbackPrizeID
} }
if req.Image != nil {
gamePrize.Image = req.Image
}
if req.Metadata != nil { if req.Metadata != nil {
gamePrize.Metadata = req.Metadata gamePrize.Metadata = req.Metadata
} }

View File

@ -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"`
}

View File

@ -14,6 +14,7 @@ type CreateGamePrizeRequest struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
} }
@ -24,6 +25,7 @@ type UpdateGamePrizeRequest struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,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"` Metadata map[string]interface{} `json:"metadata,omitempty"`
} }
@ -36,6 +38,7 @@ type GamePrizeResponse struct {
MaxStock *int `json:"max_stock,omitempty"` MaxStock *int `json:"max_stock,omitempty"`
Threshold *int64 `json:"threshold,omitempty"` Threshold *int64 `json:"threshold,omitempty"`
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
Image *string `json:"image,omitempty"`
Metadata map[string]interface{} `json:"metadata"` Metadata map[string]interface{} `json:"metadata"`
Game *GameResponse `json:"game,omitempty"` Game *GameResponse `json:"game,omitempty"`
FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"`

View File

@ -30,8 +30,6 @@ type customerAuthProcessor struct {
otpRepo repository.OtpRepository otpRepo repository.OtpRepository
jwtSecret string jwtSecret string
tokenTTLMinutes int tokenTTLMinutes int
otpStorage map[string]*models.OtpSession // In-memory storage for OTP sessions
registrationStorage map[string]*models.RegistrationSession // In-memory storage for registration sessions
} }
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor { func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
@ -41,8 +39,6 @@ func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository
otpRepo: otpRepo, otpRepo: otpRepo,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
tokenTTLMinutes: tokenTTLMinutes, tokenTTLMinutes: tokenTTLMinutes,
otpStorage: make(map[string]*models.OtpSession),
registrationStorage: make(map[string]*models.RegistrationSession),
} }
} }
@ -155,17 +151,19 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont
return nil, fmt.Errorf("failed to create OTP session: %w", err) return nil, fmt.Errorf("failed to create OTP session: %w", err)
} }
// Store registration session // Store registration data in OTP session metadata
registrationSession := &models.RegistrationSession{ registrationData := map[string]interface{}{
Token: registrationToken, "registration_token": registrationToken,
PhoneNumber: req.PhoneNumber, "name": req.Name,
Name: req.Name, "birth_date": req.BirthDate,
BirthDate: req.BirthDate, "step": "otp_sent",
ExpiresAt: time.Now().Add(10 * time.Minute),
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 // Send OTP via WhatsApp
if err := p.otpProcessor.SendOtpViaWhatsApp(req.PhoneNumber, otpSession.Code, "registration"); err != nil { 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) { func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.RegisterVerifyOtpRequest) (*models.RegisterVerifyOtpResponse, error) {
// Get registration session otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
registrationSession, exists := p.registrationStorage[req.RegistrationToken] if err != nil {
if !exists { return nil, fmt.Errorf("failed to get OTP session: %w", err)
}
if otpSession == nil {
return nil, fmt.Errorf("invalid or expired registration token") return nil, fmt.Errorf("invalid or expired registration token")
} }
if time.Now().After(registrationSession.ExpiresAt) { if otpSession.IsExpired() {
delete(p.registrationStorage, req.RegistrationToken)
return nil, fmt.Errorf("registration token expired") return nil, fmt.Errorf("registration token expired")
} }
// Validate OTP format
if !p.otpProcessor.ValidateOtpCode(req.OtpCode) { if !p.otpProcessor.ValidateOtpCode(req.OtpCode) {
return &models.RegisterVerifyOtpResponse{ return &models.RegisterVerifyOtpResponse{
Status: "FAILED", Status: "FAILED",
@ -203,34 +202,33 @@ func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.Reg
}, nil }, 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 { if otpSession.Code != req.OtpCode {
otpSession.IncrementAttempts() 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{ return &models.RegisterVerifyOtpResponse{
Status: "FAILED", Status: "FAILED",
Message: "Invalid OTP code.", Message: "Invalid OTP code.",
}, nil }, 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 // Mark OTP as used
otpSession.MarkAsUsed() 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 { if err := p.otpRepo.UpdateOtpSession(ctx, otpSession); err != nil {
return &models.RegisterVerifyOtpResponse{ return &models.RegisterVerifyOtpResponse{
Status: "FAILED", Status: "FAILED",
@ -238,10 +236,6 @@ func (p *customerAuthProcessor) VerifyOtp(ctx context.Context, req *contract.Reg
}, nil }, nil
} }
// Update registration session
registrationSession.Step = "otp_verified"
p.registrationStorage[req.RegistrationToken] = registrationSession
return &models.RegisterVerifyOtpResponse{ return &models.RegisterVerifyOtpResponse{
Status: "OTP_VERIFIED", Status: "OTP_VERIFIED",
Message: "OTP verified, continue to set password.", 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) { func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.RegisterSetPasswordRequest) (*models.RegisterSetPasswordResponse, error) {
// Validate passwords match
if req.Password != req.ConfirmPassword { if req.Password != req.ConfirmPassword {
return nil, fmt.Errorf("passwords do not match") return nil, fmt.Errorf("passwords do not match")
} }
// Get registration session // Get OTP session by registration token from metadata
registrationSession, exists := p.registrationStorage[req.RegistrationToken] otpSession, err := p.otpRepo.GetOtpSessionByRegistrationToken(ctx, req.RegistrationToken)
if !exists { 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") return nil, fmt.Errorf("invalid or expired registration token")
} }
if time.Now().After(registrationSession.ExpiresAt) { if otpSession.IsExpired() {
delete(p.registrationStorage, req.RegistrationToken)
return nil, fmt.Errorf("registration token expired") 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") 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) 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 // Parse birth date
birthDate, err := time.Parse("2006-01-02", registrationSession.BirthDate) birthDate, err := time.Parse("2006-01-02", birthDateStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid birth date format: %w", err) 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{ customer := &entities.Customer{
Name: registrationSession.Name, OrganizationID: defaultOrgID,
PhoneNumber: &registrationSession.PhoneNumber, Name: name,
PhoneNumber: &otpSession.PhoneNumber,
BirthDate: &birthDate, BirthDate: &birthDate,
PasswordHash: &passwordHashStr, PasswordHash: &passwordHashStr,
IsActive: true, 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
} }
if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil { if err := p.customerAuthRepo.CreateCustomer(ctx, customer); err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err) 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) accessToken, refreshToken, _, err := util.GenerateCustomerTokens(customer, p.jwtSecret, p.tokenTTLMinutes)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate tokens: %w", err) 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, ID: customer.ID,
Name: customer.Name, Name: customer.Name,
PhoneNumber: *customer.PhoneNumber, PhoneNumber: *customer.PhoneNumber,
BirthDate: registrationSession.BirthDate, BirthDate: birthDate.Format("2006-01-02"),
}, },
}, },
}, nil }, nil

View File

@ -13,11 +13,13 @@ import (
type CustomerPointsProcessor struct { type CustomerPointsProcessor struct {
customerPointsRepo repository.CustomerPointsRepository customerPointsRepo repository.CustomerPointsRepository
gameRepo *repository.GameRepository
} }
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository) *CustomerPointsProcessor { func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
return &CustomerPointsProcessor{ return &CustomerPointsProcessor{
customerPointsRepo: customerPointsRepo, customerPointsRepo: customerPointsRepo,
gameRepo: gameRepo,
} }
} }
@ -220,3 +222,92 @@ func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, cust
}, },
}, nil }, 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
}

View File

@ -19,7 +19,7 @@ type GamePlayProcessor struct {
gameRepo *repository.GameRepository gameRepo *repository.GameRepository
gamePrizeRepo *repository.GamePrizeRepository gamePrizeRepo *repository.GamePrizeRepository
customerTokensRepo *repository.CustomerTokensRepository customerTokensRepo *repository.CustomerTokensRepository
customerPointsRepo *repository.CustomerPointsRepository customerPointsRepo repository.CustomerPointsRepository
} }
func NewGamePlayProcessor( func NewGamePlayProcessor(
@ -27,7 +27,7 @@ func NewGamePlayProcessor(
gameRepo *repository.GameRepository, gameRepo *repository.GameRepository,
gamePrizeRepo *repository.GamePrizeRepository, gamePrizeRepo *repository.GamePrizeRepository,
customerTokensRepo *repository.CustomerTokensRepository, customerTokensRepo *repository.CustomerTokensRepository,
customerPointsRepo *repository.CustomerPointsRepository, customerPointsRepo repository.CustomerPointsRepository,
) *GamePlayProcessor { ) *GamePlayProcessor {
return &GamePlayProcessor{ return &GamePlayProcessor{
gamePlayRepo: gamePlayRepo, gamePlayRepo: gamePlayRepo,

View File

@ -86,3 +86,27 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e
} }
return games, nil 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
}

View File

@ -15,6 +15,7 @@ type OtpRepository interface {
GetOtpSessionByToken(ctx context.Context, token string) (*entities.OtpSession, error) GetOtpSessionByToken(ctx context.Context, token string) (*entities.OtpSession, error)
GetOtpSessionByPhoneAndPurpose(ctx context.Context, phoneNumber string, purpose 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) 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 UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error
DeleteOtpSession(ctx context.Context, token string) error DeleteOtpSession(ctx context.Context, token string) error
DeleteExpiredOtpSessions(ctx context.Context) error DeleteExpiredOtpSessions(ctx context.Context) error
@ -73,6 +74,18 @@ func (r *otpRepository) GetLastOtpSessionByPhoneAndPurpose(ctx context.Context,
return &otpSession, nil 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 { func (r *otpRepository) UpdateOtpSession(ctx context.Context, otpSession *entities.OtpSession) error {
if err := r.db.WithContext(ctx).Save(otpSession).Error; err != nil { if err := r.db.WithContext(ctx).Save(otpSession).Error; err != nil {
return fmt.Errorf("failed to update OTP session: %w", err) return fmt.Errorf("failed to update OTP session: %w", err)

View File

@ -194,6 +194,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/points", r.customerPointsHandler.GetCustomerPoints)
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/games", r.customerPointsHandler.GetCustomerGames)
customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame)
} }
organizations := v1.Group("/organizations") organizations := v1.Group("/organizations")

View File

@ -12,6 +12,8 @@ type CustomerPointsService interface {
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, 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 { type customerPointsService struct {
@ -62,3 +64,21 @@ func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerI
return response, nil 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
}

View File

@ -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)) responses := make([]contract.CustomerPointsResponse, len(model.Data))
for i, item := range model.Data { for i, item := range model.Data {
responses[i] = *CustomerPointsModelToResponse(&item) responses[i] = *CustomerPointsModelToResponse(&item)
@ -57,10 +61,10 @@ func PaginatedCustomerPointsResponseToContract(model *models.PaginatedResponse[m
return &contract.PaginatedCustomerPointsResponse{ return &contract.PaginatedCustomerPointsResponse{
Data: responses, Data: responses,
TotalCount: int(model.Pagination.Total), TotalCount: model.TotalCount,
Page: model.Pagination.Page, Page: model.Page,
Limit: model.Pagination.Limit, Limit: model.Limit,
TotalPages: model.Pagination.TotalPages, TotalPages: model.TotalPages,
} }
} }

View File

@ -0,0 +1,3 @@
-- Remove image field from game_prizes table
ALTER TABLE game_prizes
DROP COLUMN image;

View File

@ -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';

View File

@ -0,0 +1,3 @@
-- Remove metadata field from otp_sessions table
ALTER TABLE otp_sessions
DROP COLUMN metadata;

View File

@ -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';