diff --git a/db/migrations/000004_virtual_game_Sessios.up.sql b/db/migrations/000004_virtual_game_Sessios.up.sql index 09606ba..8ce89d0 100644 --- a/db/migrations/000004_virtual_game_Sessios.up.sql +++ b/db/migrations/000004_virtual_game_Sessios.up.sql @@ -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); diff --git a/db/query/virtual_games.sql b/db/query/virtual_games.sql index e04a24e..102cc78 100644 --- a/db/query/virtual_games.sql +++ b/db/query/virtual_games.sql @@ -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 diff --git a/docs/docs.go b/docs/docs.go index 9e8338a..ca94814 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": [ diff --git a/docs/swagger.json b/docs/swagger.json index 0d51eec..3160c79 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 3bc00f1..5911b4a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/gen/db/models.go b/gen/db/models.go index 767f121..3f62b18 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/gen/db/virtual_games.sql.go b/gen/db/virtual_games.sql.go index eb832e7..a2e0de0 100644 --- a/gen/db/virtual_games.sql.go +++ b/gen/db/virtual_games.sql.go @@ -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 diff --git a/internal/domain/virtual_game.go b/internal/domain/virtual_game.go index 0c5af92..ff35ead 100644 --- a/internal/domain/virtual_game.go +++ b/internal/domain/virtual_game.go @@ -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" +} diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go index 3b5277b..c174a36 100644 --- a/internal/repository/virtual_game.go +++ b/internal/repository/virtual_game.go @@ -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 { diff --git a/internal/services/virtualGame/port.go b/internal/services/virtualGame/port.go index 6a80458..173598f 100644 --- a/internal/services/virtualGame/port.go +++ b/internal/services/virtualGame/port.go @@ -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) } diff --git a/internal/services/virtualGame/service.go b/internal/services/virtualGame/service.go index 9633b85..b65f2e7 100644 --- a/internal/services/virtualGame/service.go +++ b/internal/services/virtualGame/service.go @@ -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 +} diff --git a/internal/web_server/handlers/virtual_games_hadlers.go b/internal/web_server/handlers/virtual_games_hadlers.go index 940c6c0..5fb0337 100644 --- a/internal/web_server/handlers/virtual_games_hadlers.go +++ b/internal/web_server/handlers/virtual_games_hadlers.go @@ -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) +} diff --git a/internal/web_server/jwt/jwt.go b/internal/web_server/jwt/jwt.go index 2617873..e1b4068 100644 --- a/internal/web_server/jwt/jwt.go +++ b/internal/web_server/jwt/jwt.go @@ -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()), diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 4c9d673..6fa1c57 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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) }