Merge branch 'main' into ticket-bet

This commit is contained in:
Samuel Tariku 2025-06-19 19:12:32 +03:00
commit 1e49afc5ee
14 changed files with 767 additions and 25 deletions

View File

@ -40,6 +40,28 @@ CREATE TABLE virtual_game_transactions (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE virtual_game_histories (
id BIGSERIAL PRIMARY KEY,
session_id VARCHAR(100), -- nullable
user_id BIGINT NOT NULL,
wallet_id BIGINT, -- nullable
game_id BIGINT, -- nullable
transaction_type VARCHAR(20) NOT NULL, -- e.g., BET, WIN, CANCEL
amount BIGINT NOT NULL, -- in cents or smallest currency unit
currency VARCHAR(10) NOT NULL,
external_transaction_id VARCHAR(100) NOT NULL,
reference_transaction_id VARCHAR(100), -- nullable, for cancel/refund
status VARCHAR(20) NOT NULL DEFAULT 'COMPLETED', -- transaction status
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Optional: Indexes for performance
CREATE INDEX idx_virtual_game_user_id ON virtual_game_histories(user_id);
CREATE INDEX idx_virtual_game_transaction_type ON virtual_game_histories(transaction_type);
CREATE INDEX idx_virtual_game_game_id ON virtual_game_histories(game_id);
CREATE INDEX idx_virtual_game_external_transaction_id ON virtual_game_histories(external_transaction_id);
CREATE INDEX idx_virtual_game_sessions_user_id ON virtual_game_sessions(user_id);
CREATE INDEX idx_virtual_game_transactions_session_id ON virtual_game_transactions(session_id);
CREATE INDEX idx_virtual_game_transactions_user_id ON virtual_game_transactions(user_id);

View File

@ -22,6 +22,36 @@ INSERT INTO virtual_game_transactions (
$1, $2, $3, $4, $5, $6, $7, $8
) RETURNING id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at;
-- name: CreateVirtualGameHistory :one
INSERT INTO virtual_game_histories (
session_id,
user_id,
wallet_id,
game_id,
transaction_type,
amount,
currency,
external_transaction_id,
reference_transaction_id,
status
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING
id,
session_id,
user_id,
wallet_id,
game_id,
transaction_type,
amount,
currency,
external_transaction_id,
reference_transaction_id,
status,
created_at,
updated_at;
-- name: GetVirtualGameTransactionByExternalID :one
SELECT id, session_id, user_id, wallet_id, transaction_type, amount, currency, external_transaction_id, status, created_at, updated_at
FROM virtual_game_transactions

View File

@ -2887,6 +2887,85 @@ const docTemplate = `{
}
}
},
"/popok/games": {
"get": {
"description": "Retrieves the list of available PopOK slot games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Virtual Games - PopOK"
],
"summary": "Get PopOK Games List",
"parameters": [
{
"type": "string",
"default": "USD",
"description": "Currency (e.g. USD, ETB)",
"name": "currency",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.PopOKGame"
}
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/popok/games/recommend": {
"get": {
"description": "Recommends games based on user history or randomly",
"produces": [
"application/json"
],
"tags": [
"Virtual Games - PopOK"
],
"summary": "Recommend virtual games",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "user_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.GameRecommendation"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/random/bet": {
"post": {
"description": "Generate a random bet",
@ -4314,7 +4393,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Handle PopOK game callback",
"parameters": [
@ -4365,7 +4444,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Launch a PopOK virtual game",
"parameters": [
@ -5057,6 +5136,30 @@ const docTemplate = `{
"STATUS_REMOVED"
]
},
"domain.GameRecommendation": {
"type": "object",
"properties": {
"bets": {
"type": "array",
"items": {
"type": "number"
}
},
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"reason": {
"description": "e.g., \"Based on your activity\", \"Popular\", \"Random pick\"",
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.League": {
"type": "object",
"properties": {
@ -5232,6 +5335,29 @@ const docTemplate = `{
}
}
},
"domain.PopOKGame": {
"type": "object",
"properties": {
"bets": {
"type": "array",
"items": {
"type": "number"
}
},
"gameName": {
"type": "string"
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.RandomBetReq": {
"type": "object",
"required": [

View File

@ -2879,6 +2879,85 @@
}
}
},
"/popok/games": {
"get": {
"description": "Retrieves the list of available PopOK slot games",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Virtual Games - PopOK"
],
"summary": "Get PopOK Games List",
"parameters": [
{
"type": "string",
"default": "USD",
"description": "Currency (e.g. USD, ETB)",
"name": "currency",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.PopOKGame"
}
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/popok/games/recommend": {
"get": {
"description": "Recommends games based on user history or randomly",
"produces": [
"application/json"
],
"tags": [
"Virtual Games - PopOK"
],
"summary": "Recommend virtual games",
"parameters": [
{
"type": "integer",
"description": "User ID",
"name": "user_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.GameRecommendation"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/domain.ErrorResponse"
}
}
}
}
},
"/random/bet": {
"post": {
"description": "Generate a random bet",
@ -4306,7 +4385,7 @@
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Handle PopOK game callback",
"parameters": [
@ -4357,7 +4436,7 @@
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Launch a PopOK virtual game",
"parameters": [
@ -5049,6 +5128,30 @@
"STATUS_REMOVED"
]
},
"domain.GameRecommendation": {
"type": "object",
"properties": {
"bets": {
"type": "array",
"items": {
"type": "number"
}
},
"game_id": {
"type": "integer"
},
"game_name": {
"type": "string"
},
"reason": {
"description": "e.g., \"Based on your activity\", \"Popular\", \"Random pick\"",
"type": "string"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.League": {
"type": "object",
"properties": {
@ -5224,6 +5327,29 @@
}
}
},
"domain.PopOKGame": {
"type": "object",
"properties": {
"bets": {
"type": "array",
"items": {
"type": "number"
}
},
"gameName": {
"type": "string"
},
"id": {
"type": "integer"
},
"status": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
},
"domain.RandomBetReq": {
"type": "object",
"required": [

View File

@ -355,6 +355,22 @@ definitions:
- STATUS_SUSPENDED
- STATUS_DECIDED_BY_FA
- STATUS_REMOVED
domain.GameRecommendation:
properties:
bets:
items:
type: number
type: array
game_id:
type: integer
game_name:
type: string
reason:
description: e.g., "Based on your activity", "Popular", "Random pick"
type: string
thumbnail:
type: string
type: object
domain.League:
properties:
bet365_id:
@ -482,6 +498,21 @@ definitions:
description: BET, WIN, REFUND, JACKPOT_WIN
type: string
type: object
domain.PopOKGame:
properties:
bets:
items:
type: number
type: array
gameName:
type: string
id:
type: integer
status:
type: integer
thumbnail:
type: string
type: object
domain.RandomBetReq:
properties:
branch_id:
@ -3444,6 +3475,58 @@ paths:
summary: Create a operation
tags:
- branch
/popok/games:
get:
consumes:
- application/json
description: Retrieves the list of available PopOK slot games
parameters:
- default: USD
description: Currency (e.g. USD, ETB)
in: query
name: currency
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.PopOKGame'
type: array
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Get PopOK Games List
tags:
- Virtual Games - PopOK
/popok/games/recommend:
get:
description: Recommends games based on user history or randomly
parameters:
- description: User ID
in: query
name: user_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/domain.GameRecommendation'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/domain.ErrorResponse'
summary: Recommend virtual games
tags:
- Virtual Games - PopOK
/random/bet:
post:
consumes:
@ -4397,7 +4480,7 @@ paths:
$ref: '#/definitions/response.APIResponse'
summary: Handle PopOK game callback
tags:
- virtual-game
- Virtual Games - PopOK
/virtual-game/launch:
post:
consumes:
@ -4433,7 +4516,7 @@ paths:
- Bearer: []
summary: Launch a PopOK virtual game
tags:
- virtual-game
- Virtual Games - PopOK
/wallet:
get:
consumes:

View File

@ -454,6 +454,22 @@ type VirtualGame struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type VirtualGameHistory struct {
ID int64 `json:"id"`
SessionID pgtype.Text `json:"session_id"`
UserID int64 `json:"user_id"`
WalletID pgtype.Int8 `json:"wallet_id"`
GameID pgtype.Int8 `json:"game_id"`
TransactionType string `json:"transaction_type"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
ExternalTransactionID string `json:"external_transaction_id"`
ReferenceTransactionID pgtype.Text `json:"reference_transaction_id"`
Status string `json:"status"`
CreatedAt pgtype.Timestamp `json:"created_at"`
UpdatedAt pgtype.Timestamp `json:"updated_at"`
}
type VirtualGameSession struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`

View File

@ -11,6 +11,81 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const CreateVirtualGameHistory = `-- name: CreateVirtualGameHistory :one
INSERT INTO virtual_game_histories (
session_id,
user_id,
wallet_id,
game_id,
transaction_type,
amount,
currency,
external_transaction_id,
reference_transaction_id,
status
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING
id,
session_id,
user_id,
wallet_id,
game_id,
transaction_type,
amount,
currency,
external_transaction_id,
reference_transaction_id,
status,
created_at,
updated_at
`
type CreateVirtualGameHistoryParams struct {
SessionID pgtype.Text `json:"session_id"`
UserID int64 `json:"user_id"`
WalletID pgtype.Int8 `json:"wallet_id"`
GameID pgtype.Int8 `json:"game_id"`
TransactionType string `json:"transaction_type"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
ExternalTransactionID string `json:"external_transaction_id"`
ReferenceTransactionID pgtype.Text `json:"reference_transaction_id"`
Status string `json:"status"`
}
func (q *Queries) CreateVirtualGameHistory(ctx context.Context, arg CreateVirtualGameHistoryParams) (VirtualGameHistory, error) {
row := q.db.QueryRow(ctx, CreateVirtualGameHistory,
arg.SessionID,
arg.UserID,
arg.WalletID,
arg.GameID,
arg.TransactionType,
arg.Amount,
arg.Currency,
arg.ExternalTransactionID,
arg.ReferenceTransactionID,
arg.Status,
)
var i VirtualGameHistory
err := row.Scan(
&i.ID,
&i.SessionID,
&i.UserID,
&i.WalletID,
&i.GameID,
&i.TransactionType,
&i.Amount,
&i.Currency,
&i.ExternalTransactionID,
&i.ReferenceTransactionID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const CreateVirtualGameSession = `-- name: CreateVirtualGameSession :one
INSERT INTO virtual_game_sessions (
user_id, game_id, session_token, currency, status, expires_at

View File

@ -38,6 +38,22 @@ type VirtualGameSession struct {
GameMode string `json:"game_mode"` // real, demo, tournament
}
type VirtualGameHistory struct {
ID int64 `json:"id"`
SessionID string `json:"session_id,omitempty"` // Optional, if session tracking is used
UserID int64 `json:"user_id"`
WalletID *int64 `json:"wallet_id,omitempty"` // Optional if wallet detail is needed
GameID *int64 `json:"game_id,omitempty"` // Optional for game-level analysis
TransactionType string `json:"transaction_type"` // BET, WIN, CANCEL, etc.
Amount int64 `json:"amount"` // Stored in minor units (e.g. cents)
Currency string `json:"currency"` // e.g., ETB, USD
ExternalTransactionID string `json:"external_transaction_id"` // Provider transaction ID
ReferenceTransactionID string `json:"reference_transaction_id,omitempty"` // For CANCELs pointing to BETs
Status string `json:"status"` // COMPLETED, CANCELLED, FAILED, etc.
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type VirtualGameTransaction struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
@ -191,3 +207,27 @@ type GameSpecificData struct {
RiskLevel string `json:"risk_level,omitempty"` // For Mines
BucketIndex int `json:"bucket_index,omitempty"` // For Plinko
}
type PopOKGame struct {
ID int `json:"id"`
GameName string `json:"gameName"`
Bets []float64 `json:"bets"`
Thumbnail string `json:"thumbnail"`
Status int `json:"status"`
}
type PopOKGameListResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Slots []PopOKGame `json:"slots"`
} `json:"data"`
}
type GameRecommendation struct {
GameID int `json:"game_id"`
GameName string `json:"game_name"`
Thumbnail string `json:"thumbnail"`
Bets []float64 `json:"bets"`
Reason string `json:"reason"` // e.g., "Based on your activity", "Popular", "Random pick"
}

View File

@ -21,6 +21,8 @@ type VirtualGameRepository interface {
// WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error)
GetUserGameHistory(ctx context.Context, userID int64) ([]domain.VirtualGameHistory, error)
CreateVirtualGameHistory(ctx context.Context, his *domain.VirtualGameHistory) error
}
type VirtualGameRepo struct {
@ -92,6 +94,21 @@ func (r *VirtualGameRepo) CreateVirtualGameTransaction(ctx context.Context, tx *
return err
}
func (r *VirtualGameRepo) CreateVirtualGameHistory(ctx context.Context, his *domain.VirtualGameHistory) error {
params := dbgen.CreateVirtualGameHistoryParams{
SessionID: pgtype.Text{String: his.SessionID, Valid: true},
UserID: his.UserID,
WalletID: pgtype.Int8{Int64: *his.WalletID, Valid: true},
TransactionType: his.TransactionType,
Amount: his.Amount,
Currency: his.Currency,
ExternalTransactionID: his.ExternalTransactionID,
Status: his.Status,
}
_, err := r.store.queries.CreateVirtualGameHistory(ctx, params)
return err
}
func (r *VirtualGameRepo) GetVirtualGameTransactionByExternalID(ctx context.Context, externalID string) (*domain.VirtualGameTransaction, error) {
dbTx, err := r.store.queries.GetVirtualGameTransactionByExternalID(ctx, externalID)
if err != nil {
@ -153,6 +170,24 @@ func (r *VirtualGameRepo) GetGameCounts(ctx context.Context, filter domain.Repor
return total, active, inactive, nil
}
func (r *VirtualGameRepo) GetUserGameHistory(ctx context.Context, userID int64) ([]domain.VirtualGameHistory, error) {
query := `SELECT game_id FROM virtual_game_histories WHERE user_id = $1 AND transaction_type = 'BET' ORDER BY created_at DESC LIMIT 100`
rows, err := r.store.conn.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var history []domain.VirtualGameHistory
for rows.Next() {
var tx domain.VirtualGameHistory
if err := rows.Scan(&tx.GameID); err == nil {
history = append(history, tx)
}
}
return history, nil
}
// func (r *VirtualGameRepo) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
// _, tx, err := r.store.BeginTx(ctx)
// if err != nil {

View File

@ -15,4 +15,6 @@ type VirtualGameService interface {
ProcessCancel(ctx context.Context, req *domain.PopOKCancelRequest) (*domain.PopOKCancelResponse, error)
GetGameCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error)
ListGames(ctx context.Context, currency string) ([]domain.PopOKGame, error)
RecommendGames(ctx context.Context, userID int64) ([]domain.GameRecommendation, error)
}

View File

@ -1,6 +1,7 @@
package virtualgameservice
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
@ -8,7 +9,12 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"sort"
"strconv"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
@ -43,14 +49,14 @@ func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameI
return "", err
}
sessionToken := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano())
sessionId := fmt.Sprintf("%d-%s-%d", userID, gameID, time.Now().UnixNano())
token, err := jwtutil.CreatePopOKJwt(
userID,
user.PhoneNumber,
currency,
"en",
mode,
sessionToken,
sessionId,
s.config.PopOK.SecretKey,
24*time.Hour,
)
@ -59,19 +65,31 @@ func (s *service) GenerateGameLaunchURL(ctx context.Context, userID int64, gameI
return "", err
}
// Record game launch as a transaction (for history and recommendation purposes)
tx := &domain.VirtualGameHistory{
SessionID: sessionId, // Optional: populate if session tracking is implemented
UserID: userID,
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, tx); err != nil {
s.logger.Error("Failed to record game launch transaction", "error", err)
// Do not fail game launch on logging error — just log and continue
}
params := fmt.Sprintf(
"partnerId=%s&gameId=%s&gameMode=%s&lang=en&platform=%s&externalToken=%s",
s.config.PopOK.ClientID, gameID, mode, s.config.PopOK.Platform, token,
)
// params = fmt.Sprintf(
// "partnerId=%s&gameId=%sgameMode=%s&lang=en&platform=%s",
// "1", "1", "fun", "111",
// )
// signature := s.generateSignature(params)
return fmt.Sprintf("%s?%s", s.config.PopOK.BaseURL, params), nil
// return fmt.Sprintf("%s?%s", s.config.PopOK.BaseURL, params), nil
}
func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCallback) error {
@ -148,10 +166,10 @@ func (s *service) HandleCallback(ctx context.Context, callback *domain.PopOKCall
func (s *service) GetPlayerInfo(ctx context.Context, req *domain.PopOKPlayerInfoRequest) (*domain.PopOKPlayerInfoResponse, error) {
claims, err := jwtutil.ParsePopOKJwt(req.ExternalToken, s.config.PopOK.SecretKey)
// if err != nil {
// s.logger.Error("Failed to parse JWT", "error", err)
// return nil, fmt.Errorf("invalid token")
// }
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 {
@ -170,9 +188,9 @@ func (s *service) GetPlayerInfo(ctx context.Context, req *domain.PopOKPlayerInfo
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")
// }
if err != nil {
return nil, fmt.Errorf("invalid token")
}
// Convert amount to cents (assuming wallet uses cents)
amountCents := int64(req.Amount * 100)
@ -399,3 +417,126 @@ func (s *service) verifySignature(callback *domain.PopOKCallback) bool {
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") // currency can be dynamic
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
}

View File

@ -19,7 +19,7 @@ type launchVirtualGameRes struct {
// LaunchVirtualGame godoc
// @Summary Launch a PopOK virtual game
// @Description Generates a URL to launch a PopOK game
// @Tags virtual-game
// @Tags Virtual Games - PopOK
// @Accept json
// @Produce json
// @Security Bearer
@ -60,7 +60,7 @@ func (h *Handler) LaunchVirtualGame(c *fiber.Ctx) error {
// HandleVirtualGameCallback godoc
// @Summary Handle PopOK game callback
// @Description Processes callbacks from PopOK for game events
// @Tags virtual-game
// @Tags Virtual Games - PopOK
// @Accept json
// @Produce json
// @Param callback body domain.PopOKCallback true "Callback data"
@ -155,3 +155,47 @@ func (h *Handler) HandleCancel(c *fiber.Ctx) error {
return response.WriteJSON(c, fiber.StatusOK, "Cancel processed", resp, nil)
}
// GetGameList godoc
// @Summary Get PopOK Games List
// @Description Retrieves the list of available PopOK slot games
// @Tags Virtual Games - PopOK
// @Accept json
// @Produce json
// @Param currency query string false "Currency (e.g. USD, ETB)" default(USD)
// @Success 200 {array} domain.PopOKGame
// @Failure 502 {object} domain.ErrorResponse
// @Router /popok/games [get]
func (h *Handler) GetGameList(c *fiber.Ctx) error {
currency := c.Query("currency", "ETB") // fallback default
games, err := h.virtualGameSvc.ListGames(c.Context(), currency)
if err != nil {
return fiber.NewError(fiber.StatusBadGateway, "failed to fetch games")
}
return c.JSON(games)
}
// RecommendGames godoc
// @Summary Recommend virtual games
// @Description Recommends games based on user history or randomly
// @Tags Virtual Games - PopOK
// @Produce json
// @Param user_id query int true "User ID"
// @Success 200 {array} domain.GameRecommendation
// @Failure 500 {object} domain.ErrorResponse
// @Router /popok/games/recommend [get]
func (h *Handler) RecommendGames(c *fiber.Ctx) error {
userIDVal := c.Locals("user_id")
userID, ok := userIDVal.(int64)
if !ok || userID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "invalid user ID")
}
recommendations, err := h.virtualGameSvc.RecommendGames(c.Context(), userID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "failed to recommend games")
}
return c.JSON(recommendations)
}

View File

@ -57,7 +57,7 @@ func CreateJwt(userId int64, Role domain.Role, CompanyID domain.ValidInt64, key
func CreatePopOKJwt(userID int64, username, currency, lang, mode, sessionID, key string, expiry time.Duration) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, PopOKClaim{
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "fortune-bet",
Issuer: "github.com/lafetz/snippitstash",
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{"popokgaming.com"},
NotBefore: jwt.NewNumericDate(time.Now()),

View File

@ -271,6 +271,8 @@ func (a *App) initAppRoutes() {
a.fiber.Post("/bet", h.HandleBet)
a.fiber.Post("/win", h.HandleWin)
a.fiber.Post("/cancel", h.HandleCancel)
a.fiber.Get("/popok/games", h.GetGameList)
a.fiber.Get("/popok/games/recommend", a.authMiddleware, h.RecommendGames)
}