diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index a7d9d93..8c168fc 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -262,6 +262,7 @@ CREATE TABLE teams ( bet365_id INT, logo_url TEXT ); + -- Views CREATE VIEW companies_details AS SELECT companies.*, 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 f8825e0..7bc5f0e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 1bb4270..031caf8 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index df02b3c..028b7e9 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/gen/db/models.go b/gen/db/models.go index b92792d..e2dad5d 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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"` diff --git a/gen/db/virtual_games.sql.go b/gen/db/virtual_games.sql.go index 16034ee..94cdeca 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 a0bbb4e..626c5fe 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 1118aaa..91c4dd4 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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) }