recommendation service

This commit is contained in:
Yared Yemane 2025-05-28 13:07:58 +03:00
parent 169e22e3a7
commit 5dcefa377f
18 changed files with 339 additions and 9 deletions

View File

@ -23,6 +23,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
@ -88,6 +89,7 @@ func main() {
notificationRepo := repository.NewNotificationRepository(store)
referalRepo := repository.NewReferralRepository(store)
vitualGameRepo := repository.NewVirtualGameRepository(store)
recommendationRepo := repository.NewRecommendationRepository(store)
notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
referalSvc := referralservice.New(referalRepo, *walletSvc, store, cfg, logger)
@ -98,13 +100,13 @@ func main() {
cfg,
logger,
)
veliService := veli.NewVeliPlayService(
vitualGameRepo,
*walletSvc,
cfg,
logger,
)
recommendationSvc := recommendation.NewService(recommendationRepo)
httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc)
httpserver.StartTicketCrons(*ticketSvc)
@ -113,7 +115,7 @@ func main() {
JwtAccessKey: cfg.JwtKey,
JwtAccessExpiry: cfg.AccessExpiry,
}, userSvc,
ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, resultSvc, cfg)
ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, companySvc, notificationSvc, oddsSvc, eventSvc, referalSvc, virtualGameSvc, aleaService, veliService, recommendationSvc, resultSvc, cfg)
logger.Info("Starting server", "port", cfg.Port)
if err := app.Run(); err != nil {

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS results (
id BIGSERIAL PRIMARY KEY,
bet_outcome_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
odd_id BIGINT NOT NULL,
market_id BIGINT NOT NULL,
status INT NOT NULL,
score VARCHAR(255),
full_time_score VARCHAR(255),
half_time_score VARCHAR(255),
ss VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bet_outcome_id) REFERENCES bet_outcomes (id)
);

View File

@ -0,0 +1,28 @@
CREATE TABLE virtual_games (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
provider VARCHAR(100) NOT NULL,
category VARCHAR(100) NOT NULL,
min_bet DECIMAL(15,2) NOT NULL,
max_bet DECIMAL(15,2) NOT NULL,
volatility VARCHAR(50) NOT NULL,
rtp DECIMAL(5,2) NOT NULL,
is_featured BOOLEAN DEFAULT false,
popularity_score INTEGER DEFAULT 0,
thumbnail_url TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_game_interactions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
game_id BIGINT NOT NULL REFERENCES virtual_games(id),
interaction_type VARCHAR(50) NOT NULL, -- 'view', 'play', 'bet', 'favorite'
amount DECIMAL(15,2),
duration_seconds INTEGER,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_game_interactions_user ON user_game_interactions(user_id);
CREATE INDEX idx_user_game_interactions_game ON user_game_interactions(game_id);

View File

@ -493,6 +493,45 @@ const docTemplate = `{
}
}
},
"/api/v1/virtual-games/recommendations/{userID}": {
"get": {
"description": "Returns a list of recommended virtual games for a specific user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Recommendations"
],
"summary": "Get virtual game recommendations",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "userID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Recommended games fetched successfully",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Failed to fetch recommendations",
"schema": {
"$ref": "#/definitions/domain.RecommendationErrorResponse"
}
}
}
}
},
"/api/v1/webhooks/alea": {
"post": {
"description": "Handles webhook callbacks from Alea Play virtual games for bet settlement",
@ -4678,6 +4717,14 @@ const docTemplate = `{
}
}
},
"domain.RecommendationErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"domain.ReferralSettings": {
"type": "object",
"properties": {

View File

@ -485,6 +485,45 @@
}
}
},
"/api/v1/virtual-games/recommendations/{userID}": {
"get": {
"description": "Returns a list of recommended virtual games for a specific user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Recommendations"
],
"summary": "Get virtual game recommendations",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "userID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Recommended games fetched successfully",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"500": {
"description": "Failed to fetch recommendations",
"schema": {
"$ref": "#/definitions/domain.RecommendationErrorResponse"
}
}
}
}
},
"/api/v1/webhooks/alea": {
"post": {
"description": "Handles webhook callbacks from Alea Play virtual games for bet settlement",
@ -4670,6 +4709,14 @@
}
}
},
"domain.RecommendationErrorResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"domain.ReferralSettings": {
"type": "object",
"properties": {

View File

@ -357,6 +357,11 @@ definitions:
items: {}
type: array
type: object
domain.RecommendationErrorResponse:
properties:
message:
type: string
type: object
domain.ReferralSettings:
properties:
betReferralBonusPercentage:
@ -1761,6 +1766,32 @@ paths:
summary: Verify a transfer
tags:
- Chapa
/api/v1/virtual-games/recommendations/{userID}:
get:
consumes:
- application/json
description: Returns a list of recommended virtual games for a specific user
parameters:
- description: User ID
in: path
name: userID
required: true
type: string
produces:
- application/json
responses:
"200":
description: Recommended games fetched successfully
schema:
additionalProperties: true
type: object
"500":
description: Failed to fetch recommendations
schema:
$ref: '#/definitions/domain.RecommendationErrorResponse'
summary: Get virtual game recommendations
tags:
- Recommendations
/api/v1/webhooks/alea:
post:
consumes:

View File

@ -0,0 +1,5 @@
package domain
type RecommendationErrorResponse struct {
Message string `json:"message"`
}

View File

@ -147,7 +147,7 @@ type BaseballResultResponse struct {
EighthInning Score `json:"8"`
NinthInning Score `json:"9"`
ExtraInnings Score `json:"10"`
TotalScore Score `json:"7"`
TotalScore Score `json:"11"`
} `json:"scores"`
Stats struct {
Hits []string `json:"hits"`

View File

@ -10,7 +10,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication"
"github.com/jackc/pgx/v5/pgtype"
)
func (s *Store) GetUserByEmailPhone(ctx context.Context, email, phone string) (domain.User, error) {
user, err := s.queries.GetUserByEmailPhone(ctx, dbgen.GetUserByEmailPhoneParams{
Email: pgtype.Text{

View File

@ -0,0 +1,48 @@
package repository
import (
"context"
)
type RecommendationRepository interface {
GetUserVirtualGameInteractions(ctx context.Context, userID string) ([]UserGameInteraction, error)
}
type recommendationRepo struct {
store *Store
}
func NewRecommendationRepository(store *Store) RecommendationRepository {
return &recommendationRepo{store: store}
}
func (r *recommendationRepo) GetUserVirtualGameInteractions(ctx context.Context, userID string) ([]UserGameInteraction, error) {
var interactions []UserGameInteraction
query := `SELECT game_id, interaction_type, timestamp FROM virtual_game_interactions WHERE user_id = $1 ORDER BY timestamp DESC LIMIT 100`
rows, err := r.store.conn.Query(ctx, query, userID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var u UserGameInteraction
if err := rows.Scan(&u.GameID, &u.InteractionType, &u.Timestamp); err != nil {
return nil, err
}
interactions = append(interactions, u)
}
if rows.Err() != nil {
return nil, rows.Err()
}
return interactions, nil
}
type UserGameInteraction struct {
GameID string
InteractionType string // e.g., "view", "bet", "like"
Timestamp string
}

View File

@ -14,7 +14,6 @@ type VirtualGameRepository interface {
CreateVirtualGameSession(ctx context.Context, session *domain.VirtualGameSession) error
GetVirtualGameSessionByToken(ctx context.Context, token string) (*domain.VirtualGameSession, error)
UpdateVirtualGameSessionStatus(ctx context.Context, id int64, status string) error
// UpdateVirtualGameSessionStatus(ctx context.Context, id int64, status string) error
CreateVirtualGameTransaction(ctx context.Context, tx *domain.VirtualGameTransaction) error
GetVirtualGameTransactionByExternalID(ctx context.Context, externalID string) (*domain.VirtualGameTransaction, error)
UpdateVirtualGameTransactionStatus(ctx context.Context, id int64, status string) error

View File

@ -0,0 +1,7 @@
package recommendation
import "context"
type RecommendationService interface {
GetRecommendations(ctx context.Context, userID string) ([]string, error)
}

View File

@ -0,0 +1,65 @@
package recommendation
import (
"context"
"sort"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
)
// type RecommendationService interface {
// GetRecommendations(ctx context.Context, userID string) ([]string, error)
// }
type service struct {
repo repository.RecommendationRepository
}
func NewService(repo repository.RecommendationRepository) RecommendationService {
return &service{repo: repo}
}
func (s *service) GetRecommendations(ctx context.Context, userID string) ([]string, error) {
interactions, err := s.repo.GetUserVirtualGameInteractions(ctx, userID)
if err != nil {
return nil, err
}
recommendationMap := map[string]int{}
for _, interaction := range interactions {
score := 1
switch interaction.InteractionType {
case "bet":
score = 5
case "view":
score = 1
}
recommendationMap[interaction.GameID] += score
}
// Example: return top 3 games based on score
topGames := sortGamesByScore(recommendationMap)
return topGames, nil
}
func sortGamesByScore(scoreMap map[string]int) []string {
type kv struct {
Key string
Value int
}
var sorted []kv
for k, v := range scoreMap {
sorted = append(sorted, kv{k, v})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Value > sorted[j].Value
})
var result []string
for i := 0; i < len(sorted) && i < 3; i++ {
result = append(result, sorted[i].Key)
}
return result
}

View File

@ -38,7 +38,6 @@ func NewVeliPlayService(
}
}
// GenerateGameLaunchURL mirrors Alea's pattern but uses Veli's auth requirements
func (s *VeliPlayService) GenerateGameLaunchURL(ctx context.Context, userID int64, gameID, currency, mode string) (string, error) {
session := &domain.VirtualGameSession{
UserID: userID,
@ -71,7 +70,6 @@ func (s *VeliPlayService) GenerateGameLaunchURL(ctx context.Context, userID int6
return fmt.Sprintf("%s/launch?%s", s.config.APIURL, params.Encode()), nil
}
// HandleCallback processes Veli's webhooks (similar structure to Alea)
func (s *VeliPlayService) HandleCallback(ctx context.Context, callback *domain.VeliCallback) error {
if !s.verifyCallbackSignature(callback) {
return errors.New("invalid callback signature")
@ -114,7 +112,6 @@ func (s *VeliPlayService) HandleCallback(ctx context.Context, callback *domain.V
return nil
}
// Shared helper methods (same pattern as Alea)
func (s *VeliPlayService) generateSignature(data string) string {
h := hmac.New(sha256.New, []byte(s.config.SecretKey))
h.Write([]byte(data))

View File

@ -11,6 +11,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/company"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/result"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
@ -33,6 +34,7 @@ type App struct {
fiber *fiber.App
aleaVirtualGameService alea.AleaVirtualGameService
veliVirtualGameService veli.VeliVirtualGameService
recommendationSvc recommendation.RecommendationService
cfg *config.Config
logger *slog.Logger
NotidicationStore *notificationservice.Service
@ -74,6 +76,7 @@ func NewApp(
virtualGameSvc virtualgameservice.VirtualGameService,
aleaVirtualGameService alea.AleaVirtualGameService,
veliVirtualGameService veli.VeliVirtualGameService,
recommendationSvc recommendation.RecommendationService,
resultSvc *result.Service,
cfg *config.Config,
) *App {
@ -113,6 +116,7 @@ func NewApp(
virtualGameSvc: virtualGameSvc,
aleaVirtualGameService: aleaVirtualGameService,
veliVirtualGameService: veliVirtualGameService,
recommendationSvc: recommendationSvc,
resultSvc: resultSvc,
cfg: cfg,
}

View File

@ -11,6 +11,7 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/event"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation"
referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction"
@ -39,6 +40,7 @@ type Handler struct {
virtualGameSvc virtualgameservice.VirtualGameService
aleaVirtualGameSvc alea.AleaVirtualGameService
veliVirtualGameSvc veli.VeliVirtualGameService
recommendationSvc recommendation.RecommendationService
authSvc *authentication.Service
jwtConfig jwtutil.JwtConfig
validator *customvalidator.CustomValidator
@ -54,6 +56,7 @@ func New(
virtualGameSvc virtualgameservice.VirtualGameService,
aleaVirtualGameSvc alea.AleaVirtualGameService,
veliVirtualGameSvc veli.VeliVirtualGameService,
recommendationSvc recommendation.RecommendationService,
userSvc *user.Service,
transactionSvc *transaction.Service,
ticketSvc *ticket.Service,
@ -83,6 +86,7 @@ func New(
virtualGameSvc: virtualGameSvc,
aleaVirtualGameSvc: aleaVirtualGameSvc,
veliVirtualGameSvc: veliVirtualGameSvc,
recommendationSvc: recommendationSvc,
authSvc: authSvc,
jwtConfig: jwtConfig,
Cfg: cfg,

View File

@ -0,0 +1,26 @@
package handlers
import (
"github.com/gofiber/fiber/v2"
)
// @Summary Get virtual game recommendations
// @Description Returns a list of recommended virtual games for a specific user
// @Tags Recommendations
// @Accept json
// @Produce json
// @Param userID path string true "User ID"
// @Success 200 {object} map[string]interface{} "Recommended games fetched successfully"
// @Failure 500 {object} domain.RecommendationErrorResponse "Failed to fetch recommendations"
// @Router /api/v1/virtual-games/recommendations/{userID} [get]
func (h *Handler) GetRecommendations(c *fiber.Ctx) error {
userID := c.Params("userID") // or from JWT
recommendations, err := h.recommendationSvc.GetRecommendations(c.Context(), userID)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch recommendations")
}
return c.JSON(fiber.Map{
"message": "Recommended games fetched successfully",
"recommended_games": recommendations,
})
}

View File

@ -23,6 +23,7 @@ func (a *App) initAppRoutes() {
a.virtualGameSvc,
a.aleaVirtualGameService,
a.veliVirtualGameService,
a.recommendationSvc,
a.userSvc,
a.transactionSvc,
a.ticketSvc,
@ -36,6 +37,8 @@ func (a *App) initAppRoutes() {
a.cfg,
)
group := a.fiber.Group("/api/v1")
a.fiber.Get("/", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"message": "Welcome to the FortuneBet API",
@ -177,7 +180,6 @@ func (a *App) initAppRoutes() {
a.fiber.Post("/transfer/refill/:id", a.authMiddleware, h.RefillWallet)
//Chapa Routes
group := a.fiber.Group("/api/v1")
group.Post("/chapa/payments/initialize", h.InitializePayment)
group.Get("/chapa/payments/verify/:tx_ref", h.VerifyTransaction)
@ -194,6 +196,9 @@ func (a *App) initAppRoutes() {
group.Get("/veli-games/launch", a.authMiddleware, h.LaunchVeliGame)
group.Post("/webhooks/veli-games", a.authMiddleware, h.HandleVeliCallback)
// Recommendation Routes
group.Get("/virtual-games/recommendations/:userID", a.authMiddleware, h.GetRecommendations)
// Transactions /transactions
a.fiber.Post("/transaction", a.authMiddleware, h.CreateTransaction)
a.fiber.Get("/transaction", a.authMiddleware, h.GetAllTransactions)