game list

This commit is contained in:
Yared Yemane 2025-06-19 15:02:28 +03:00
parent 22ec5d3ff8
commit 7c70b23a3d
15 changed files with 882 additions and 504 deletions

View File

@ -262,6 +262,7 @@ CREATE TABLE teams (
bet365_id INT,
logo_url TEXT
);
-- Views
CREATE VIEW companies_details AS
SELECT companies.*,

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

@ -811,76 +811,6 @@ const docTemplate = `{
}
}
},
"/api/veli/launch/{game_id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Generates authenticated launch URL for Veli games",
"tags": [
"Veli Games"
],
"summary": "Launch a Veli game",
"parameters": [
{
"type": "string",
"description": "Game ID (e.g., veli_aviator_v1)",
"name": "game_id",
"in": "path",
"required": true
},
{
"type": "string",
"default": "USD",
"description": "Currency code",
"name": "currency",
"in": "query"
},
{
"enum": [
"real",
"demo"
],
"type": "string",
"default": "real",
"description": "Game mode",
"name": "mode",
"in": "query"
}
],
"responses": {
"200": {
"description": "Returns launch URL",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/auth/login": {
"post": {
"description": "Login customer",
@ -2957,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",
@ -4352,7 +4361,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Handle PopOK game callback",
"parameters": [
@ -4403,7 +4412,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Launch a PopOK virtual game",
"parameters": [
@ -4577,70 +4586,6 @@ const docTemplate = `{
}
}
}
},
"/webhooks/veli": {
"post": {
"description": "Processes game round settlements from Veli",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Veli Games"
],
"summary": "Veli Games webhook handler",
"parameters": [
{
"description": "Callback payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.VeliCallback"
}
}
],
"responses": {
"200": {
"description": "Callback processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid payload",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Invalid signature",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Processing error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
@ -5113,6 +5058,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": {
@ -5193,6 +5162,17 @@ const docTemplate = `{
}
}
},
"domain.OtpProvider": {
"type": "string",
"enum": [
"twilio",
"aformessage"
],
"x-enum-varnames": [
"TwilioSms",
"AfroMessage"
]
},
"domain.OutcomeStatus": {
"type": "integer",
"enum": [
@ -5273,6 +5253,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": [
@ -5551,51 +5554,6 @@ const docTemplate = `{
}
}
},
"domain.VeliCallback": {
"type": "object",
"properties": {
"amount": {
"description": "Transaction amount",
"type": "number"
},
"currency": {
"description": "e.g., \"USD\"",
"type": "string"
},
"event_type": {
"description": "\"bet_placed\", \"game_result\", etc.",
"type": "string"
},
"game_id": {
"description": "e.g., \"veli_aviator_v1\"",
"type": "string"
},
"multiplier": {
"description": "For games with multipliers (Aviator/Plinko)",
"type": "number"
},
"round_id": {
"description": "Unique round identifier (replaces transaction_id)",
"type": "string"
},
"session_id": {
"description": "Matches VirtualGameSession.SessionToken",
"type": "string"
},
"signature": {
"description": "HMAC-SHA256",
"type": "string"
},
"timestamp": {
"description": "Unix timestamp",
"type": "integer"
},
"user_id": {
"description": "Veli's user identifier",
"type": "string"
}
}
},
"domain.VirtualGame": {
"type": "object",
"properties": {
@ -6249,6 +6207,9 @@ const docTemplate = `{
},
"handlers.RegisterCodeReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6257,11 +6218,22 @@ const docTemplate = `{
"phone_number": {
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
}
}
},
"handlers.RegisterUserReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6287,6 +6259,14 @@ const docTemplate = `{
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
},
"referal_code": {
"type": "string",
"example": "ABC123"
@ -6295,6 +6275,9 @@ const docTemplate = `{
},
"handlers.ResetCodeReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6303,6 +6286,14 @@ const docTemplate = `{
"phone_number": {
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
}
}
},

View File

@ -803,76 +803,6 @@
}
}
},
"/api/veli/launch/{game_id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Generates authenticated launch URL for Veli games",
"tags": [
"Veli Games"
],
"summary": "Launch a Veli game",
"parameters": [
{
"type": "string",
"description": "Game ID (e.g., veli_aviator_v1)",
"name": "game_id",
"in": "path",
"required": true
},
{
"type": "string",
"default": "USD",
"description": "Currency code",
"name": "currency",
"in": "query"
},
{
"enum": [
"real",
"demo"
],
"type": "string",
"default": "real",
"description": "Game mode",
"name": "mode",
"in": "query"
}
],
"responses": {
"200": {
"description": "Returns launch URL",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/auth/login": {
"post": {
"description": "Login customer",
@ -2949,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",
@ -4344,7 +4353,7 @@
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Handle PopOK game callback",
"parameters": [
@ -4395,7 +4404,7 @@
"application/json"
],
"tags": [
"virtual-game"
"Virtual Games - PopOK"
],
"summary": "Launch a PopOK virtual game",
"parameters": [
@ -4569,70 +4578,6 @@
}
}
}
},
"/webhooks/veli": {
"post": {
"description": "Processes game round settlements from Veli",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Veli Games"
],
"summary": "Veli Games webhook handler",
"parameters": [
{
"description": "Callback payload",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.VeliCallback"
}
}
],
"responses": {
"200": {
"description": "Callback processed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid payload",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Invalid signature",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Processing error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
@ -5105,6 +5050,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": {
@ -5185,6 +5154,17 @@
}
}
},
"domain.OtpProvider": {
"type": "string",
"enum": [
"twilio",
"aformessage"
],
"x-enum-varnames": [
"TwilioSms",
"AfroMessage"
]
},
"domain.OutcomeStatus": {
"type": "integer",
"enum": [
@ -5265,6 +5245,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": [
@ -5543,51 +5546,6 @@
}
}
},
"domain.VeliCallback": {
"type": "object",
"properties": {
"amount": {
"description": "Transaction amount",
"type": "number"
},
"currency": {
"description": "e.g., \"USD\"",
"type": "string"
},
"event_type": {
"description": "\"bet_placed\", \"game_result\", etc.",
"type": "string"
},
"game_id": {
"description": "e.g., \"veli_aviator_v1\"",
"type": "string"
},
"multiplier": {
"description": "For games with multipliers (Aviator/Plinko)",
"type": "number"
},
"round_id": {
"description": "Unique round identifier (replaces transaction_id)",
"type": "string"
},
"session_id": {
"description": "Matches VirtualGameSession.SessionToken",
"type": "string"
},
"signature": {
"description": "HMAC-SHA256",
"type": "string"
},
"timestamp": {
"description": "Unix timestamp",
"type": "integer"
},
"user_id": {
"description": "Veli's user identifier",
"type": "string"
}
}
},
"domain.VirtualGame": {
"type": "object",
"properties": {
@ -6241,6 +6199,9 @@
},
"handlers.RegisterCodeReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6249,11 +6210,22 @@
"phone_number": {
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
}
}
},
"handlers.RegisterUserReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6279,6 +6251,14 @@
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
},
"referal_code": {
"type": "string",
"example": "ABC123"
@ -6287,6 +6267,9 @@
},
"handlers.ResetCodeReq": {
"type": "object",
"required": [
"provider"
],
"properties": {
"email": {
"type": "string",
@ -6295,6 +6278,14 @@
"phone_number": {
"type": "string",
"example": "1234567890"
},
"provider": {
"allOf": [
{
"$ref": "#/definitions/domain.OtpProvider"
}
],
"example": "twilio"
}
}
},

View File

@ -323,6 +323,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:
@ -378,6 +394,14 @@ definitions:
source:
type: string
type: object
domain.OtpProvider:
enum:
- twilio
- aformessage
type: string
x-enum-varnames:
- TwilioSms
- AfroMessage
domain.OutcomeStatus:
enum:
- 0
@ -439,6 +463,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:
@ -632,39 +671,6 @@ definitions:
- $ref: '#/definitions/domain.EventStatus'
description: Match Status for event
type: object
domain.VeliCallback:
properties:
amount:
description: Transaction amount
type: number
currency:
description: e.g., "USD"
type: string
event_type:
description: '"bet_placed", "game_result", etc.'
type: string
game_id:
description: e.g., "veli_aviator_v1"
type: string
multiplier:
description: For games with multipliers (Aviator/Plinko)
type: number
round_id:
description: Unique round identifier (replaces transaction_id)
type: string
session_id:
description: Matches VirtualGameSession.SessionToken
type: string
signature:
description: HMAC-SHA256
type: string
timestamp:
description: Unix timestamp
type: integer
user_id:
description: Veli's user identifier
type: string
type: object
domain.VirtualGame:
properties:
category:
@ -1126,6 +1132,12 @@ definitions:
phone_number:
example: "1234567890"
type: string
provider:
allOf:
- $ref: '#/definitions/domain.OtpProvider'
example: twilio
required:
- provider
type: object
handlers.RegisterUserReq:
properties:
@ -1147,9 +1159,15 @@ definitions:
phone_number:
example: "1234567890"
type: string
provider:
allOf:
- $ref: '#/definitions/domain.OtpProvider'
example: twilio
referal_code:
example: ABC123
type: string
required:
- provider
type: object
handlers.ResetCodeReq:
properties:
@ -1159,6 +1177,12 @@ definitions:
phone_number:
example: "1234567890"
type: string
provider:
allOf:
- $ref: '#/definitions/domain.OtpProvider'
example: twilio
required:
- provider
type: object
handlers.ResetPasswordReq:
properties:
@ -2077,52 +2101,6 @@ paths:
summary: Process Alea Play game callback
tags:
- Alea Virtual Games
/api/veli/launch/{game_id}:
get:
description: Generates authenticated launch URL for Veli games
parameters:
- description: Game ID (e.g., veli_aviator_v1)
in: path
name: game_id
required: true
type: string
- default: USD
description: Currency code
in: query
name: currency
type: string
- default: real
description: Game mode
enum:
- real
- demo
in: query
name: mode
type: string
responses:
"200":
description: Returns launch URL
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Launch a Veli game
tags:
- Veli Games
/auth/login:
post:
consumes:
@ -3494,6 +3472,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:
@ -4426,7 +4456,7 @@ paths:
$ref: '#/definitions/response.APIResponse'
summary: Handle PopOK game callback
tags:
- virtual-game
- Virtual Games - PopOK
/virtual-game/launch:
post:
consumes:
@ -4462,7 +4492,7 @@ paths:
- Bearer: []
summary: Launch a PopOK virtual game
tags:
- virtual-game
- Virtual Games - PopOK
/wallet:
get:
consumes:
@ -4551,48 +4581,6 @@ paths:
summary: Activate and Deactivate Wallet
tags:
- wallet
/webhooks/veli:
post:
consumes:
- application/json
description: Processes game round settlements from Veli
parameters:
- description: Callback payload
in: body
name: payload
required: true
schema:
$ref: '#/definitions/domain.VeliCallback'
produces:
- application/json
responses:
"200":
description: Callback processed
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid payload
schema:
additionalProperties:
type: string
type: object
"403":
description: Invalid signature
schema:
additionalProperties:
type: string
type: object
"500":
description: Processing error
schema:
additionalProperties:
type: string
type: object
summary: Veli Games webhook handler
tags:
- Veli Games
securityDefinitions:
Bearer:
in: header

View File

@ -445,6 +445,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

@ -270,6 +270,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)
}