package virtualgameservice import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand/v2" "net/http" "sort" "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" ) type service struct { repo repository.VirtualGameRepository walletSvc wallet.Service store *repository.Store // virtualGameStore repository.VirtualGameRepository config *config.Config logger *slog.Logger } func New(repo repository.VirtualGameRepository, walletSvc wallet.Service, store *repository.Store, cfg *config.Config, logger *slog.Logger) VirtualGameService { return &service{ repo: repo, walletSvc: walletSvc, store: store, config: cfg, logger: logger, } } func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) { // 1. Fetch user user, err := s.store.GetUserByID(ctx, userID) if err != nil { s.logger.Error("Failed to get user", "userID", userID, "error", err) return "", err } // 2. Generate session and token sessionID := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano()) token, err := jwtutil.CreatePopOKJwt( userID, user.CompanyID, user.PhoneNumber, currency, "en", mode, sessionID, s.config.PopOK.SecretKey, 24*time.Hour, ) if err != nil { s.logger.Error("Failed to create PopOK JWT", "userID", userID, "error", err) return "", err } // 3. Record virtual game history (optional but recommended) history := &domain.VirtualGameHistory{ SessionID: sessionID, UserID: userID, CompanyID: user.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: toInt64Ptr(gameID), TransactionType: "LAUNCH", Amount: 0, Currency: currency, ExternalTransactionID: sessionID, Status: "COMPLETED", CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.CreateVirtualGameHistory(ctx, history); err != nil { s.logger.Error("Failed to record game launch transaction", "error", err) // Non-fatal: log and continue } // 4. Prepare PopOK API request timestamp := time.Now().Format("02-01-2006 15:04:05") partnerID, err := strconv.Atoi(s.config.PopOK.ClientID) if err != nil { return "", fmt.Errorf("invalid PopOK ClientID: %v", err) } data := domain.PopokLaunchRequestData{ GameMode: mode, GameID: gameID, Lang: "en", Token: token, ExitURL: "", } hash, err := generatePopOKHash(s.config.PopOK.SecretKey, timestamp, data) if err != nil { return "", fmt.Errorf("failed to generate PopOK hash: %w", err) } platformInt, err := strconv.Atoi(s.config.PopOK.Platform) if err != nil { return "", fmt.Errorf("invalid PopOK Platform: %v", err) } reqBody := domain.PopokLaunchRequest{ Action: "getLauncherURL", Platform: platformInt, PartnerID: partnerID, Time: timestamp, Hash: hash, Data: data, } // 5. Make API request bodyBytes, _ := json.Marshal(reqBody) resp, err := http.Post(s.config.PopOK.BaseURL+"/serviceApi.php", "application/json", bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("PopOK POST failed: %w", err) } defer resp.Body.Close() var parsedResp domain.PopokLaunchResponse if err := json.NewDecoder(resp.Body).Decode(&parsedResp); err != nil { return "", fmt.Errorf("failed to parse PopOK response: %w", err) } if parsedResp.Code != 0 { return "", fmt.Errorf("PopOK error: %s", parsedResp.Message) } return parsedResp.Data.LauncherURL, nil } func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error { s.logger.Info("Handling PopOK callback", "transactionID", callback.TransactionID, "type", callback.Type) if !s.verifySignature(callback) { s.logger.Error("Invalid callback signature", "transactionID", callback.TransactionID) return errors.New("invalid signature") } existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, callback.TransactionID) if err != nil { s.logger.Error("Failed to check existing transaction", "transactionID", callback.TransactionID, "error", err) return err } if existingTx != nil { s.logger.Warn("Transaction already processed", "transactionID", callback.TransactionID) return nil // Idempotency } session, err := s.repo.GetVirtualGameSessionByToken(ctx, callback.SessionID) if err != nil || session == nil { s.logger.Error("Invalid or missing session", "sessionID", callback.SessionID, "error", err) return errors.New("invalid session") } wallets, err := s.walletSvc.GetWalletsByUser(ctx, session.UserID) if err != nil || len(wallets) == 0 { s.logger.Error("Failed to get wallets or no wallet found", "userID", session.UserID, "error", err) return errors.New("user has no wallet") } walletID := wallets[0].ID amount := int64(callback.Amount) // Convert to cents transactionType := callback.Type switch transactionType { case "BET": amount = -amount // Debit for bets case "WIN", "JACKPOT_WIN", "REFUND": default: s.logger.Error("Unknown transaction type", "transactionID", callback.TransactionID, "type", transactionType) return errors.New("unknown transaction type") } _, err = s.walletSvc.AddToWallet(ctx, walletID, domain.Currency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet for winning PopOkBet", amount), ) if err != nil { s.logger.Error("Failed to update wallet", "walletID", walletID, "userID", session.UserID, "amount", amount, "error", err) return err } // Record transaction tx := &domain.VirtualGameTransaction{ SessionID: session.ID, UserID: session.UserID, WalletID: walletID, TransactionType: transactionType, Amount: amount, Currency: callback.Currency, ExternalTransactionID: callback.TransactionID, Status: "COMPLETED", CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { s.logger.Error("Failed to create transaction", "transactionID", callback.TransactionID, "error", err) return err } s.logger.Info("Callback processed successfully", "transactionID", callback.TransactionID, "type", transactionType, "amount", callback.Amount) return nil } func (s *service) GetPlayerInfo(ctx context.Context, req *domain.PopOKPlayerInfoRequest) (*domain.PopOKPlayerInfoResponse, error) { claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) fmt.Printf("\n\nClaims: %+v\n\n", claims) fmt.Printf("\n\nExternal token: %+v\n\n", req.ExternalToken) if err != nil { s.logger.Error("Failed to parse JWT", "error", err) return nil, fmt.Errorf("invalid token") } wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { s.logger.Error("No wallets found for user", "userID", claims.UserID) return nil, err } return &domain.PopOKPlayerInfoResponse{ Country: "ET", Currency: claims.Currency, Balance: float64(wallet.RegularBalance.Float32()), // Convert cents to currency PlayerID: fmt.Sprintf("%d", claims.UserID), }, nil } func (s *service) ProcessBet(ctx context.Context, req *domain.PopOKBetRequest) (*domain.PopOKBetResponse, error) { // Validate external token and extract user context claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { return nil, fmt.Errorf("invalid or expired token: %w", err) } // Validate required fields if req.Amount <= 0 { return nil, errors.New("invalid bet amount") } if req.TransactionID == "" { return nil, errors.New("missing transaction_id") } // Retrieve user's wallet wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err) } // Deduct amount from wallet _, err = s.walletSvc.DeductFromWallet( ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, // optional linked transfer ID domain.TRANSFER_DIRECT, fmt.Sprintf("Virtual game bet placed (PopOK) - Txn: %s", req.TransactionID), ) if err != nil { return nil, fmt.Errorf("failed to deduct from wallet (insufficient balance or wallet error): %w", err) } // Record the transaction tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, CompanyID: claims.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: req.GameID, TransactionType: "BET", Amount: -int64(req.Amount), // Represent bet as negative in accounting Currency: req.Currency, ExternalTransactionID: req.TransactionID, Status: "COMPLETED", CreatedAt: time.Now(), } if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { // Optionally rollback wallet deduction if transaction record fails _, addErr := s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Rollback refund for failed bet transaction (PopOK) - Txn: %s", req.TransactionID), ) if addErr != nil { return nil, fmt.Errorf("failed to credit wallet: %w", addErr) } s.logger.Error("Failed to create bet transaction", "error", err, "txn_id", req.TransactionID, "user_id", claims.UserID) return nil, fmt.Errorf("failed to record bet transaction: %w", err) } // Return updated wallet balance updatedWallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to refresh wallet balance: %w", err) } return &domain.PopOKBetResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), // internal reference Balance: float64(updatedWallet.RegularBalance.Float32()), }, nil } func (s *service) ProcessWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) { // 1. Validate token and get user ID claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { s.logger.Error("Invalid token in win request", "error", err) return nil, fmt.Errorf("invalid token") } fmt.Printf("\n\nClaims: %+v\n\n", claims) // 2. Check for duplicate transaction (idempotency) existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID) if err != nil { s.logger.Error("Failed to check existing transaction", "error", err) return nil, fmt.Errorf("transaction check failed") } if existingTx != nil && existingTx.TransactionType == "WIN" { s.logger.Warn("Duplicate win transaction", "transactionID", req.TransactionID) wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) // balance := 0.0 // if len(wallets) > 0 { // balance = float64(wallets[0].Balance) // } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%d", existingTx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } // 3. Convert amount to cents // amountCents := int64(req.Amount) wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to read user wallets") } // 4. Credit to wallet _, err = s.walletSvc.AddToWallet(ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("Added %v to wallet for winning PopOkBet", req.Amount), ) if err != nil { s.logger.Error("Failed to credit wallet", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("wallet credit failed") } // userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) // if err != nil { // return &domain.PopOKWinResponse{}, fmt.Errorf("Failed to read user wallets") // } // 5. Create transaction record tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, CompanyID: claims.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: req.GameID, TransactionType: "WIN", Amount: int64(req.Amount), Currency: req.Currency, ExternalTransactionID: req.TransactionID, Status: "COMPLETED", CreatedAt: time.Now(), } if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { s.logger.Error("Failed to create win transaction", "error", err) return nil, fmt.Errorf("transaction recording failed") } fmt.Printf("\n\n Win balance is:%v\n\n", float64(wallet.RegularBalance.Float32())) return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } func (s *service) ProcessTournamentWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) { // --- 1. Validate token and get user ID --- claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { s.logger.Error("Invalid token in tournament win request", "error", err) return nil, fmt.Errorf("invalid token") } // --- 2. Check for duplicate tournament win transaction (idempotency) --- existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID) if err != nil { s.logger.Error("Failed to check existing tournament transaction", "error", err) return nil, fmt.Errorf("transaction check failed") } if existingTx != nil && existingTx.TransactionType == "TOURNAMENT_WIN" { s.logger.Warn("Duplicate tournament win", "transactionID", req.TransactionID) wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", existingTx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } // --- 3. Fetch wallet --- wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err) } // --- 4. Credit user wallet --- _, err = s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.TransactionID, Valid: true, }, }, fmt.Sprintf("Added %v to wallet for winning PopOK Tournament", req.Amount), ) if err != nil { s.logger.Error("Failed to credit wallet for tournament", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("wallet credit failed") } // --- 5. Record tournament win transaction --- tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, CompanyID: claims.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: req.GameID, TransactionType: "TOURNAMENT_WIN", Amount: int64(req.Amount), Currency: req.Currency, ExternalTransactionID: req.TransactionID, Status: "COMPLETED", CreatedAt: time.Now(), } if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { s.logger.Error("Failed to record tournament win transaction", "error", err) return nil, fmt.Errorf("transaction recording failed") } // --- 6. Return response with updated balance --- return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } func (s *service) ProcessPromoWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) { // --- 1. Validate token and get user ID --- claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { s.logger.Error("Invalid token in promo win request", "error", err) return nil, fmt.Errorf("invalid token") } // --- 2. Check for duplicate promo win transaction (idempotency) --- existingTx, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID) if err != nil { s.logger.Error("Failed to check existing promo transaction", "error", err) return nil, fmt.Errorf("transaction check failed") } if existingTx != nil && existingTx.TransactionType == "PROMO_WIN" { s.logger.Warn("Duplicate promo win", "transactionID", req.TransactionID) wallet, _ := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", existingTx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } // --- 3. Fetch wallet --- wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err) } // --- 4. Credit user wallet --- _, err = s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.Currency(req.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.TransactionID, Valid: true, }, }, fmt.Sprintf("Added %v to wallet for winning PopOK Promo Win", req.Amount), ) if err != nil { s.logger.Error("Failed to credit wallet for promo", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("wallet credit failed") } // --- 5. Record promo win transaction --- tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, CompanyID: claims.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: req.GameID, TransactionType: "PROMO_WIN", Amount: int64(req.Amount), Currency: req.Currency, ExternalTransactionID: req.TransactionID, Status: "COMPLETED", CreatedAt: time.Now(), } if err := s.repo.CreateVirtualGameTransaction(ctx, tx); err != nil { s.logger.Error("Failed to record promo win transaction", "error", err) return nil, fmt.Errorf("transaction recording failed") } // --- 6. Return response with updated balance --- return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } func (s *service) ProcessCancel(ctx context.Context, req *domain.PopOKCancelRequest) (*domain.PopOKCancelResponse, error) { // --- 1. Validate token and get user ID --- claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { s.logger.Error("Invalid token in cancel request", "error", err) return nil, fmt.Errorf("invalid token") } // --- 2. Fetch wallet --- wallet, err := s.walletSvc.GetCustomerWallet(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to fetch wallet for user %d: %w", claims.UserID, err) } // --- 3. Find the original bet transaction --- originalBet, err := s.repo.GetVirtualGameTransactionByExternalID(ctx, req.TransactionID) if err != nil { s.logger.Error("Failed to find original bet", "transactionID", req.TransactionID, "error", err) return nil, fmt.Errorf("original bet not found") } // --- 4. Validate original transaction --- if originalBet == nil || originalBet.TransactionType != "BET" { s.logger.Error("Invalid original transaction for cancel", "transactionID", req.TransactionID) return nil, fmt.Errorf("invalid original transaction") } // --- 5. Check for duplicate cancellation --- if originalBet.Status == "CANCELLED" { s.logger.Warn("Transaction already cancelled", "transactionID", req.TransactionID) return &domain.PopOKCancelResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", originalBet.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } // --- 6. Refund the bet amount --- refundAmount := -originalBet.Amount // Bet amounts are negative _, err = s.walletSvc.AddToWallet( ctx, wallet.RegularID, domain.Currency(refundAmount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{ ReferenceNumber: domain.ValidString{ Value: req.TransactionID, Valid: true, }, }, fmt.Sprintf("Refunded %v to wallet for cancelling PopOK bet", refundAmount), ) if err != nil { s.logger.Error("Failed to refund bet", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("refund failed") } // --- 7. Mark original bet as cancelled and create cancel record --- cancelTx := &domain.VirtualGameTransaction{ UserID: claims.UserID, TransactionType: "CANCEL", Amount: refundAmount, Currency: originalBet.Currency, ExternalTransactionID: req.TransactionID, ReferenceTransactionID: fmt.Sprintf("%v", originalBet.ID), Status: "COMPLETED", CreatedAt: time.Now(), } // Update original transaction status if err := s.repo.UpdateVirtualGameTransactionStatus(ctx, originalBet.ID, "CANCELLED"); err != nil { s.logger.Error("Failed to update transaction status", "error", err) return nil, fmt.Errorf("update failed") } // Create cancel transaction if err := s.repo.CreateVirtualGameTransaction(ctx, cancelTx); err != nil { s.logger.Error("Failed to create cancel transaction", "error", err) // Attempt to revert original transaction status if revertErr := s.repo.UpdateVirtualGameTransactionStatus(ctx, originalBet.ID, originalBet.Status); revertErr != nil { s.logger.Error("Failed to revert transaction status", "error", revertErr) } return nil, fmt.Errorf("create cancel transaction failed") } // --- 8. Return response with updated balance --- return &domain.PopOKCancelResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", cancelTx.ID), Balance: float64(wallet.RegularBalance.Float32()), }, nil } func generatePopOKHash(privateKey, timestamp string, data domain.PopokLaunchRequestData) (string, error) { // Marshal data to JSON (compact format, like json_encode in PHP) dataBytes, err := json.Marshal(data) if err != nil { return "", err } // Concatenate: privateKey + time + json_encoded(data) hashInput := fmt.Sprintf("%s%s%s", privateKey, timestamp, string(dataBytes)) // SHA-256 hash hash := sha256.Sum256([]byte(hashInput)) return hex.EncodeToString(hash[:]), nil } func (s *service) verifySignature(callback *domain.PopOKCallback) bool { data, _ := json.Marshal(struct { TransactionID string `json:"transaction_id"` SessionID string `json:"session_id"` Type string `json:"type"` Amount float64 `json:"amount"` Currency string `json:"currency"` Timestamp int64 `json:"timestamp"` }{ TransactionID: callback.TransactionID, SessionID: callback.SessionID, Type: callback.Type, Amount: callback.Amount, Currency: callback.Currency, Timestamp: callback.Timestamp, }) h := hmac.New(sha256.New, []byte(s.config.PopOK.SecretKey)) h.Write(data) expected := hex.EncodeToString(h.Sum(nil)) return expected == callback.Signature } func (s *service) ListGames(ctx context.Context, currency string) ([]domain.PopOKGame, error) { now := time.Now().Format("02-01-2006 15:04:05") // dd-mm-yyyy hh:mm:ss // Step 1: Construct payload without the hash data := map[string]interface{}{ "action": "gameList", "platform": s.config.PopOK.Platform, "partnerId": s.config.PopOK.ClientID, "currency": currency, "time": now, } // Step 2: Marshal data to JSON for hash calculation // dataBytes, err := json.Marshal(data) // if err != nil { // s.logger.Error("Failed to marshal data for hash generation", "error", err) // return nil, err // } // Step 3: Calculate hash: sha256(privateKey + time + json(data)) rawHash := s.config.PopOK.SecretKey + now hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) // Step 4: Add the hash to the payload data["hash"] = hash // Step 5: Marshal full payload with hash bodyBytes, err := json.Marshal(data) if err != nil { s.logger.Error("Failed to marshal final payload with hash", "error", err) return nil, err } // Step 6: Create and send the request req, err := http.NewRequestWithContext(ctx, "POST", s.config.PopOK.BaseURL+"/serviceApi.php", bytes.NewReader(bodyBytes)) if err != nil { s.logger.Error("Failed to create game list request", "error", err) return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36") req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Accept-Language", "en-US,en;q=0.9") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { s.logger.Error("Failed to send game list request", "error", err) return nil, err } defer resp.Body.Close() // Step 7: Handle response if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("PopOK game list failed with status %d: %s", resp.StatusCode, string(b)) } var gameList domain.PopOKGameListResponse if err := json.NewDecoder(resp.Body).Decode(&gameList); err != nil { s.logger.Error("Failed to decode game list response", "error", err) return nil, err } if gameList.Code != 0 { return nil, fmt.Errorf("PopOK error: %s", gameList.Message) } return gameList.Data.Slots, nil } func (s *service) RecommendGames(ctx context.Context, userID int64) ([]domain.GameRecommendation, error) { // Fetch all available games games, err := s.ListGames(ctx, "ETB") if err != nil || len(games) == 0 { return nil, fmt.Errorf("could not fetch games") } // Check if user has existing interaction history, err := s.repo.GetUserGameHistory(ctx, userID) if err != nil { s.logger.Warn("No previous game history", "userID", userID) } recommendations := []domain.GameRecommendation{} if len(history) > 0 { // Score games based on interaction frequency gameScores := map[int64]int{} for _, h := range history { if h.GameID != nil { gameScores[*h.GameID]++ } } // Sort by score descending sort.SliceStable(games, func(i, j int) bool { return gameScores[int64(games[i].ID)] > gameScores[int64(games[j].ID)] }) // Pick top 3 for _, g := range games[:min(3, len(games))] { recommendations = append(recommendations, domain.GameRecommendation{ GameID: g.ID, GameName: g.GameName, Thumbnail: g.Thumbnail, Bets: g.Bets, Reason: "Based on your activity", }) } } else { // Pick 3 random games for new users rand.Shuffle(len(games), func(i, j int) { games[i], games[j] = games[j], games[i] }) for _, g := range games[:min(3, len(games))] { recommendations = append(recommendations, domain.GameRecommendation{ GameID: g.ID, GameName: g.GameName, Thumbnail: g.Thumbnail, Bets: g.Bets, Reason: "Random pick", }) } } return recommendations, nil } func toInt64Ptr(s string) *int64 { id, err := strconv.ParseInt(s, 10, 64) if err != nil { return nil } return &id } func (s *service) AddFavoriteGame(ctx context.Context, userID, gameID int64) error { return s.repo.AddFavoriteGame(ctx, userID, gameID) } func (s *service) RemoveFavoriteGame(ctx context.Context, userID, gameID int64) error { return s.repo.RemoveFavoriteGame(ctx, userID, gameID) } func (s *service) ListFavoriteGames(ctx context.Context, userID int64) ([]domain.GameRecommendation, error) { gameIDs, err := s.repo.ListFavoriteGames(ctx, userID) if err != nil { s.logger.Error("Failed to list favorite games", "userID", userID, "error", err) return nil, err } if len(gameIDs) == 0 { return []domain.GameRecommendation{}, nil } allGames, err := s.ListGames(ctx, "ETB") // You can use dynamic currency if needed if err != nil { return nil, err } var favorites []domain.GameRecommendation idMap := make(map[int64]bool) for _, id := range gameIDs { idMap[id] = true } for _, g := range allGames { if idMap[int64(g.ID)] { favorites = append(favorites, domain.GameRecommendation{ GameID: g.ID, GameName: g.GameName, Thumbnail: g.Thumbnail, Bets: g.Bets, Reason: "Marked as favorite", }) } } return favorites, nil }