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 * 100) // 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{}) 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") } wallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) if err != nil || len(wallets) == 0 { s.logger.Error("No wallets found for user", "userID", claims.UserID) return nil, fmt.Errorf("no wallet found") } return &domain.PopOKPlayerInfoResponse{ Country: "ET", Currency: claims.Currency, Balance: float64(wallets[0].Balance) / 100, // 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 token and get user ID claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey) if err != nil { return nil, fmt.Errorf("invalid token") } // Convert amount to cents (assuming wallet uses cents) amountCents := int64(req.Amount * 100) // Deduct from wallet userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) if err != nil { return &domain.PopOKBetResponse{}, fmt.Errorf("Failed to read user wallets") } if _, err := s.walletSvc.DeductFromWallet(ctx, claims.UserID, domain.Currency(amountCents), domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT); err != nil { return nil, fmt.Errorf("insufficient balance") } // Create transaction record tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, CompanyID: claims.CompanyID.Value, Provider: string(domain.PROVIDER_POPOK), GameID: req.GameID, TransactionType: "BET", Amount: amountCents, // Negative for bets 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 bet transaction", "error", err) return nil, fmt.Errorf("transaction failed") } return &domain.PopOKBetResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), // Your internal transaction ID Balance: float64(userWallets[0].Balance) / 100, }, 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) wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) balance := 0.0 if len(wallets) > 0 { balance = float64(wallets[0].Balance) / 100 } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%d", existingTx.ID), Balance: balance, }, nil } // 3. Convert amount to cents amountCents := int64(req.Amount * 100) // 4. Credit to wallet if _, err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(amountCents), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); 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: amountCents, 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(userWallets[0].Balance)/100) return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(userWallets[0].Balance) / 100, }, 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 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) wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) balance := 0.0 if len(wallets) > 0 { balance = float64(wallets[0].Balance) / 100 } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", existingTx.ID), Balance: balance, }, nil } // 3. Convert amount to cents amountCents := int64(req.Amount * 100) // 4. Credit user wallet if _, err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(amountCents), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); err != nil { s.logger.Error("Failed to credit wallet for tournament", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("wallet credit failed") } // 5. Log tournament win transaction tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, TransactionType: "TOURNAMENT_WIN", Amount: amountCents, 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. Fetch updated balance wallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("Failed to get wallet balance") } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(wallets[0].Balance) / 100, }, nil } func (s *service) ProcessPromoWin(ctx context.Context, req *domain.PopOKWinRequest) (*domain.PopOKWinResponse, error) { 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") } 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) wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) balance := 0.0 if len(wallets) > 0 { balance = float64(wallets[0].Balance) / 100 } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", existingTx.ID), Balance: balance, }, nil } amountCents := int64(req.Amount * 100) if _, err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(amountCents), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); err != nil { s.logger.Error("Failed to credit wallet for promo", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("wallet credit failed") } tx := &domain.VirtualGameTransaction{ UserID: claims.UserID, TransactionType: "PROMO_WIN", Amount: amountCents, 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 promo win transaction", "error", err) return nil, fmt.Errorf("transaction recording failed") } wallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) if err != nil { return nil, fmt.Errorf("failed to read wallets") } return &domain.PopOKWinResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", tx.ID), Balance: float64(wallets[0].Balance) / 100, }, nil } // func (s *service) GenerateNewToken(ctx context.Context, req *domain.PopOKGenerateTokenRequest) (*domain.PopOKGenerateTokenResponse, error) { // userID, err := strconv.ParseInt(req.PlayerID, 10, 64) // if err != nil { // s.logger.Error("Invalid player ID", "playerID", req.PlayerID, "error", err) // return nil, fmt.Errorf("invalid player ID") // } // user, err := s.store.GetUserByID(ctx, userID) // if err != nil { // s.logger.Error("Failed to find user for token refresh", "userID", userID, "error", err) // return nil, fmt.Errorf("user not found") // } // newSessionID := fmt.Sprintf("%d-%s-%d", userID, req.GameID, time.Now().UnixNano()) // token, err := jwtutil.CreatePopOKJwt( // userID, // user.FirstName, // req.Currency, // "en", // req.Mode, // newSessionID, // s.config.PopOK.SecretKey, // 24*time.Hour, // ) // if err != nil { // s.logger.Error("Failed to generate new token", "userID", userID, "error", err) // return nil, fmt.Errorf("token generation failed") // } // return &domain.PopOKGenerateTokenResponse{ // NewToken: token, // }, 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. 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") } // 3. Validate the 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") } // 4. Check if already cancelled if originalBet.Status == "CANCELLED" { s.logger.Warn("Transaction already cancelled", "transactionID", req.TransactionID) wallets, _ := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) balance := 0.0 if len(wallets) > 0 { balance = float64(wallets[0].Balance) / 100 } return &domain.PopOKCancelResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", originalBet.ID), Balance: balance, }, nil } // 5. Refund the bet amount (absolute value since bet amount is negative) refundAmount := -originalBet.Amount if _, err := s.walletSvc.AddToWallet(ctx, claims.UserID, domain.Currency(refundAmount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}); err != nil { s.logger.Error("Failed to refund bet", "userID", claims.UserID, "error", err) return nil, fmt.Errorf("refund failed") } userWallets, err := s.walletSvc.GetWalletsByUser(ctx, claims.UserID) if err != nil { return &domain.PopOKCancelResponse{}, fmt.Errorf("Failed to read user wallets") } // 6. 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(), } 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 the status update 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 failed") } // if err != nil { // s.logger.Error("Failed to process cancel transaction", "error", err) // return nil, fmt.Errorf("transaction processing failed") // } return &domain.PopOKCancelResponse{ TransactionID: req.TransactionID, ExternalTrxID: fmt.Sprintf("%v", cancelTx.ID), Balance: float64(userWallets[0].Balance) / 100, }, 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) GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { return s.repo.GetGameCounts(ctx, filter) } 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 // Calculate hash: sha256(privateKey + time) rawHash := s.config.PopOK.SecretKey + now hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) // Construct request payload payload := map[string]interface{}{ "action": "gameList", "platform": s.config.PopOK.Platform, "partnerId": s.config.PopOK.ClientID, "currency": currency, "time": now, "hash": hash, } bodyBytes, err := json.Marshal(payload) if err != nil { s.logger.Error("Failed to marshal game list request", "error", err) return nil, err } 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") 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() 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 }