From 5dcefa377f4408950f05b3eb0f5ff69d18f23469 Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 28 May 2025 13:07:58 +0300 Subject: [PATCH] recommendation service --- cmd/main.go | 6 +- .../000005_result_checker.up copy.sql | 15 +++++ db/migrations/000006_recommendation.up.sql | 28 ++++++++ docs/docs.go | 47 ++++++++++++++ docs/swagger.json | 47 ++++++++++++++ docs/swagger.yaml | 31 +++++++++ internal/domain/recommendation.go | 5 ++ internal/domain/sports_result.go | 2 +- internal/repository/auth.go | 2 +- internal/repository/recommendation.go | 48 ++++++++++++++ internal/repository/virtual_game.go | 1 - internal/services/recommendation/port.go | 7 ++ internal/services/recommendation/service.go | 65 +++++++++++++++++++ internal/services/virtualGame/veli/service.go | 3 - internal/web_server/app.go | 4 ++ internal/web_server/handlers/handlers.go | 4 ++ .../web_server/handlers/recommendation.go | 26 ++++++++ internal/web_server/routes.go | 7 +- 18 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 db/migrations/000005_result_checker.up copy.sql create mode 100644 db/migrations/000006_recommendation.up.sql create mode 100644 internal/domain/recommendation.go create mode 100644 internal/repository/recommendation.go create mode 100644 internal/services/recommendation/port.go create mode 100644 internal/services/recommendation/service.go create mode 100644 internal/web_server/handlers/recommendation.go diff --git a/cmd/main.go b/cmd/main.go index 90f5dc7..102d78b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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 { diff --git a/db/migrations/000005_result_checker.up copy.sql b/db/migrations/000005_result_checker.up copy.sql new file mode 100644 index 0000000..2cbc8fb --- /dev/null +++ b/db/migrations/000005_result_checker.up copy.sql @@ -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) +); diff --git a/db/migrations/000006_recommendation.up.sql b/db/migrations/000006_recommendation.up.sql new file mode 100644 index 0000000..6be9fc7 --- /dev/null +++ b/db/migrations/000006_recommendation.up.sql @@ -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); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 5836188..fcbae21 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 15ade76..833f9b7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7ddc40e..a1429f6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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: diff --git a/internal/domain/recommendation.go b/internal/domain/recommendation.go new file mode 100644 index 0000000..aacf605 --- /dev/null +++ b/internal/domain/recommendation.go @@ -0,0 +1,5 @@ +package domain + +type RecommendationErrorResponse struct { + Message string `json:"message"` +} diff --git a/internal/domain/sports_result.go b/internal/domain/sports_result.go index 448c4de..16bceb1 100644 --- a/internal/domain/sports_result.go +++ b/internal/domain/sports_result.go @@ -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"` diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 3f43d9e..fcc5f09 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -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{ diff --git a/internal/repository/recommendation.go b/internal/repository/recommendation.go new file mode 100644 index 0000000..6d4b475 --- /dev/null +++ b/internal/repository/recommendation.go @@ -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 +} diff --git a/internal/repository/virtual_game.go b/internal/repository/virtual_game.go index 0fa5429..cfa6fee 100644 --- a/internal/repository/virtual_game.go +++ b/internal/repository/virtual_game.go @@ -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 diff --git a/internal/services/recommendation/port.go b/internal/services/recommendation/port.go new file mode 100644 index 0000000..6cb6361 --- /dev/null +++ b/internal/services/recommendation/port.go @@ -0,0 +1,7 @@ +package recommendation + +import "context" + +type RecommendationService interface { + GetRecommendations(ctx context.Context, userID string) ([]string, error) +} diff --git a/internal/services/recommendation/service.go b/internal/services/recommendation/service.go new file mode 100644 index 0000000..bb86789 --- /dev/null +++ b/internal/services/recommendation/service.go @@ -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 +} diff --git a/internal/services/virtualGame/veli/service.go b/internal/services/virtualGame/veli/service.go index 33adb25..fc9097a 100644 --- a/internal/services/virtualGame/veli/service.go +++ b/internal/services/virtualGame/veli/service.go @@ -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)) diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 5bbf4ae..18e4411 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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, } diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 1089821..532d2da 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -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, diff --git a/internal/web_server/handlers/recommendation.go b/internal/web_server/handlers/recommendation.go new file mode 100644 index 0000000..c62215a --- /dev/null +++ b/internal/web_server/handlers/recommendation.go @@ -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, + }) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index b9024ec..261258d 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)