From 8e271559ae38755748e06e857aa481068e1657a0 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 8 May 2025 03:38:19 +0300 Subject: [PATCH 01/15] fix odd filtering --- cmd/main.go | 2 +- internal/domain/oddres.go | 50 ++++++ internal/domain/result.go | 176 -------------------- internal/domain/resultres.go | 152 +++++++++++++++++ internal/services/odds/service.go | 242 +++++++++++++++++++++++----- internal/services/result/service.go | 1 - 6 files changed, 402 insertions(+), 221 deletions(-) create mode 100644 internal/domain/oddres.go create mode 100644 internal/domain/resultres.go diff --git a/cmd/main.go b/cmd/main.go index f6fd907..d9b1089 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -69,7 +69,7 @@ func main() { userSvc := user.NewService(store, store, mockSms, mockEmail) eventSvc := event.New(cfg.Bet365Token, store) - oddsSvc := odds.New(cfg.Bet365Token, store) + oddsSvc := odds.New(store, cfg, logger) resultSvc := result.NewService(store, cfg, logger) ticketSvc := ticket.NewService(store) betSvc := bet.NewService(store) diff --git a/internal/domain/oddres.go b/internal/domain/oddres.go new file mode 100644 index 0000000..8c2707d --- /dev/null +++ b/internal/domain/oddres.go @@ -0,0 +1,50 @@ +package domain + +import "encoding/json" + +type BaseNonLiveOddResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type OddsSection struct { + UpdatedAt string `json:"updated_at"` + Sp map[string]OddsMarket `json:"sp"` +} + +type OddsMarket struct { + ID json.Number `json:"id"` + Name string `json:"name"` + Odds []json.RawMessage `json:"odds"` + Header string `json:"header,omitempty"` + Handicap string `json:"handicap,omitempty"` + Open int64 `json:"open,omitempty"` +} + +type FootballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + AsianLines OddsSection `json:"asian_lines"` + Goals OddsSection `json:"goals"` + Half OddsSection `json:"half"` +} + +type BasketballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + HalfProps OddsSection `json:"half_props"` + QuarterProps OddsSection `json:"quarter_props"` + TeamProps OddsSection `json:"team_props"` + Others []OddsSection `json:"others"` +} + +type IceHockeyOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + Main2 OddsSection `json:"main_2"` + FirstPeriod OddsSection `json:"1st_period"` + Others []OddsSection `json:"others"` +} diff --git a/internal/domain/result.go b/internal/domain/result.go index dacd634..44861d2 100644 --- a/internal/domain/result.go +++ b/internal/domain/result.go @@ -1,185 +1,9 @@ package domain import ( - "encoding/json" "time" ) -type BaseResultResponse struct { - Success int `json:"success"` - Results []json.RawMessage `json:"results"` -} -type FootballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstHalf Score `json:"1"` - SecondHalf Score `json:"2"` - } `json:"scores"` - Stats struct { - Attacks []string `json:"attacks"` - Corners []string `json:"corners"` - DangerousAttacks []string `json:"dangerous_attacks"` - Goals []string `json:"goals"` - OffTarget []string `json:"off_target"` - OnTarget []string `json:"on_target"` - Penalties []string `json:"penalties"` - PossessionRT []string `json:"possession_rt"` - RedCards []string `json:"redcards"` - Substitutions []string `json:"substitutions"` - YellowCards []string `json:"yellowcards"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - StadiumData map[string]string `json:"stadium_data"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type BasketballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstQuarter Score `json:"1"` - SecondQuarter Score `json:"2"` - FirstHalf Score `json:"3"` - ThirdQuarter Score `json:"4"` - FourthQuarter Score `json:"5"` - TotalScore Score `json:"7"` - } `json:"scores"` - Stats struct { - TwoPoints []string `json:"2points"` - ThreePoints []string `json:"3points"` - BiggestLead []string `json:"biggest_lead"` - Fouls []string `json:"fouls"` - FreeThrows []string `json:"free_throws"` - FreeThrowRate []string `json:"free_throws_rate"` - LeadChanges []string `json:"lead_changes"` - MaxpointsInarow []string `json:"maxpoints_inarow"` - Possession []string `json:"possession"` - SuccessAttempts []string `json:"success_attempts"` - TimeSpendInLead []string `json:"timespent_inlead"` - Timeuts []string `json:"time_outs"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} -type IceHockeyResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstPeriod Score `json:"1"` - SecondPeriod Score `json:"2"` - ThirdPeriod Score `json:"3"` - TotalScore Score `json:"5"` - } `json:"scores"` - - Stats struct { - Shots []string `json:"shots"` - Penalties []string `json:"penalties"` - GoalsOnPowerPlay []string `json:"goals_on_power_play"` - SSeven []string `json:"s7"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type Score struct { - Home string `json:"home"` - Away string `json:"away"` -} - type MarketConfig struct { Sport string MarketCategories map[string]bool diff --git a/internal/domain/resultres.go b/internal/domain/resultres.go new file mode 100644 index 0000000..b69a6a9 --- /dev/null +++ b/internal/domain/resultres.go @@ -0,0 +1,152 @@ +package domain + +import ( + "encoding/json" +) + +type BaseResultResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` +} + +type Score struct { + Home string `json:"home"` + Away string `json:"away"` +} + +type FootballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + } `json:"scores"` + Stats struct { + Attacks []string `json:"attacks"` + Corners []string `json:"corners"` + DangerousAttacks []string `json:"dangerous_attacks"` + Goals []string `json:"goals"` + OffTarget []string `json:"off_target"` + OnTarget []string `json:"on_target"` + Penalties []string `json:"penalties"` + PossessionRT []string `json:"possession_rt"` + RedCards []string `json:"redcards"` + Substitutions []string `json:"substitutions"` + YellowCards []string `json:"yellowcards"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +type BasketballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + FirstHalf Score `json:"3"` + ThirdQuarter Score `json:"4"` + FourthQuarter Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + TwoPoints []string `json:"2points"` + ThreePoints []string `json:"3points"` + BiggestLead []string `json:"biggest_lead"` + Fouls []string `json:"fouls"` + FreeThrows []string `json:"free_throws"` + FreeThrowRate []string `json:"free_throws_rate"` + LeadChanges []string `json:"lead_changes"` + MaxpointsInarow []string `json:"maxpoints_inarow"` + Possession []string `json:"possession"` + SuccessAttempts []string `json:"success_attempts"` + TimeSpendInLead []string `json:"timespent_inlead"` + Timeuts []string `json:"time_outs"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length string `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} +type IceHockeyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstPeriod Score `json:"1"` + SecondPeriod Score `json:"2"` + ThirdPeriod Score `json:"3"` + TotalScore Score `json:"5"` + } `json:"scores"` + + Stats struct { + Shots []string `json:"shots"` + Penalties []string `json:"penalties"` + GoalsOnPowerPlay []string `json:"goals_on_power_play"` + SSeven []string `json:"s7"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length string `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index c60e8c6..c42bab7 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -3,26 +3,37 @@ package odds import ( "context" "encoding/json" + "errors" + "fmt" "io" "log" + "log/slog" "net/http" "strconv" "time" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" ) type ServiceImpl struct { - token string - store *repository.Store + store *repository.Store + config *config.Config + logger *slog.Logger + client *http.Client } -func New(token string, store *repository.Store) *ServiceImpl { - return &ServiceImpl{token: token, store: store} +func New(store *repository.Store, cfg *config.Config, logger *slog.Logger) *ServiceImpl { + return &ServiceImpl{ + store: store, + config: cfg, + logger: logger, + client: &http.Client{Timeout: 10 * time.Second}, + } } -// TODO this is only getting the main odds, this must be fixed +// TODO Add the optimization to get 10 events at the same time func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { eventIDs, err := s.store.GetAllUpcomingEvents(ctx) if err != nil { @@ -30,60 +41,208 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { return err } + var errs []error + for _, event := range eventIDs { // time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour - eventID := event.ID - prematchURL := "https://api.b365api.com/v3/bet365/prematch?token=" + s.token + "&FI=" + eventID - log.Printf("📡 Fetching prematch odds for event ID: %s", eventID) - resp, err := http.Get(prematchURL) + eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { - log.Printf("❌ Failed to fetch prematch odds for event %s: %v", eventID, err) + s.logger.Error("Failed to parse event id") + return err + } + + url := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%d", s.config.Bet365Token, eventID) + + log.Printf("📡 Fetching prematch odds for event ID: %d", eventID) + + resp, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("❌ Failed to fetch prematch odds for event %d: %v", eventID, err) continue } + defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - var oddsData struct { - Success int `json:"success"` - Results []struct { - EventID string `json:"event_id"` - FI string `json:"FI"` - Main OddsSection `json:"main"` - } `json:"results"` - } + var oddsData domain.BaseNonLiveOddResponse + if err := json.Unmarshal(body, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { - log.Printf("❌ Invalid prematch data for event %s", eventID) + log.Printf("❌ Invalid prematch data for event %d", eventID) continue } - result := oddsData.Results[0] - finalID := result.EventID - if finalID == "" { - finalID = result.FI + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + + switch sportID { + case domain.FOOTBALL: + if err := s.parseFootball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert football odd") + errs = append(errs, err) + } + case domain.BASKETBALL: + if err := s.parseBasketball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert basketball odd") + errs = append(errs, err) + } + case domain.ICE_HOCKEY: + if err := s.parseIceHockey(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert ice hockey odd") + errs = append(errs, err) + } + } - if finalID == "" { - log.Printf("⚠️ Skipping event %s with no valid ID", eventID) - continue - } - s.storeSection(ctx, finalID, result.FI, "main", result.Main) + + // result := oddsData.Results[0] + } return nil } -func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { +func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) error { + var footballRes domain.FootballOddsResponse + if err := json.Unmarshal(res, &footballRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if footballRes.EventID == "" && footballRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": footballRes.Main, + "asian_lines": footballRes.AsianLines, + "goals": footballRes.Goals, + "half": footballRes.Half, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, footballRes.EventID, footballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) parseBasketball(ctx context.Context, res json.RawMessage) error { + var basketballRes domain.BasketballOddsResponse + if err := json.Unmarshal(res, &basketballRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if basketballRes.EventID == "" && basketballRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": basketballRes.Main, + "half_props": basketballRes.HalfProps, + "quarter_props": basketballRes.QuarterProps, + "team_props": basketballRes.TeamProps, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range basketballRes.Others { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} +func (s *ServiceImpl) parseIceHockey(ctx context.Context, res json.RawMessage) error { + var iceHockeyRes domain.IceHockeyOddsResponse + if err := json.Unmarshal(res, &iceHockeyRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if iceHockeyRes.EventID == "" && iceHockeyRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": iceHockeyRes.Main, + "main_2": iceHockeyRes.Main2, + "1st_period": iceHockeyRes.FirstPeriod, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range iceHockeyRes.Others { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section domain.OddsSection) error { if len(section.Sp) == 0 { - return + return nil } updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) updatedAt := time.Unix(updatedAtUnix, 0) + var errs []error for marketType, market := range section.Sp { if len(market.Odds) == 0 { continue } + + marketID, err := market.ID.Int64() + if err != nil { + s.logger.Error("Invalid market id", "marketID", marketID) + errs = append(errs, err) + continue + } + + isSupported, ok := domain.SupportedMarkets[marketID] + + if !ok || !isSupported { + s.logger.Info("Unsupported market_id", "marketID", marketID) + continue + } + marketRecord := domain.Market{ EventID: eventID, FI: fi, @@ -95,21 +254,18 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName Odds: market.Odds, } - _ = s.store.SaveNonLiveMarket(ctx, marketRecord) + err = s.store.SaveNonLiveMarket(ctx, marketRecord) + if err != nil { + s.logger.Error("failed to save market", "market_id", market.ID, "error", err) + errs = append(errs, fmt.Errorf("market %s: %w", market.ID, err)) + continue + } } -} -type OddsMarket struct { - ID json.Number `json:"id"` - Name string `json:"name"` - Odds []json.RawMessage `json:"odds"` - Header string `json:"header,omitempty"` - Handicap string `json:"handicap,omitempty"` -} - -type OddsSection struct { - UpdatedAt string `json:"updated_at"` - Sp map[string]OddsMarket `json:"sp"` + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil } func (s *ServiceImpl) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 74983cb..c959f00 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -66,7 +66,6 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { s.logger.Error("Sport ID is invalid", "event_id", outcome.EventID, "error", err) continue } - result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err) From b7b17fa8d23b90d2397287c0ad82edb413efdcc0 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 8 May 2025 07:29:00 +0300 Subject: [PATCH 02/15] create random bet --- cmd/main.go | 5 +- db/query/bet.sql | 6 +- gen/db/bet.sql.go | 27 +- internal/domain/bet.go | 79 ++++ internal/repository/bet.go | 7 +- internal/services/bet/port.go | 4 +- internal/services/bet/service.go | 431 +++++++++++++++++- internal/services/result/service.go | 7 +- internal/web_server/handlers/bet_handler.go | 308 ++----------- .../web_server/handlers/branch_handler.go | 4 +- internal/web_server/routes.go | 5 +- 11 files changed, 593 insertions(+), 290 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index d9b1089..364cb70 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -70,14 +70,13 @@ func main() { eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(store, cfg, logger) - resultSvc := result.NewService(store, cfg, logger) ticketSvc := ticket.NewService(store) - betSvc := bet.NewService(store) walletSvc := wallet.NewService(store, store) transactionSvc := transaction.NewService(store) branchSvc := branch.NewService(store) companySvc := company.NewService(store) - + betSvc := bet.NewService(store, eventSvc, oddsSvc, *walletSvc, *branchSvc, logger) + resultSvc := result.NewService(store, cfg, logger, *betSvc) notificationRepo := repository.NewNotificationRepository(store) referalRepo := repository.NewReferralRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store) diff --git a/db/query/bet.sql b/db/query/bet.sql index 42db5a7..aed3aa4 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -62,16 +62,16 @@ WHERE branch_id = $1; SELECT * FROM bet_outcomes WHERE event_id = $1; - -- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1; --- name: UpdateBetOutcomeStatus :exec +-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 -WHERE id = $2; +WHERE id = $2 +RETURNING *; -- name: UpdateStatus :exec UPDATE bets SET status = $2, diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index e236690..0f10df6 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -285,10 +285,11 @@ func (q *Queries) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([] return items, nil } -const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :exec +const UpdateBetOutcomeStatus = `-- name: UpdateBetOutcomeStatus :one UPDATE bet_outcomes SET status = $1 WHERE id = $2 +RETURNING id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires ` type UpdateBetOutcomeStatusParams struct { @@ -296,9 +297,27 @@ type UpdateBetOutcomeStatusParams struct { ID int64 `json:"id"` } -func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) error { - _, err := q.db.Exec(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) - return err +func (q *Queries) UpdateBetOutcomeStatus(ctx context.Context, arg UpdateBetOutcomeStatusParams) (BetOutcome, error) { + row := q.db.QueryRow(ctx, UpdateBetOutcomeStatus, arg.Status, arg.ID) + var i BetOutcome + err := row.Scan( + &i.ID, + &i.BetID, + &i.SportID, + &i.EventID, + &i.OddID, + &i.HomeTeamName, + &i.AwayTeamName, + &i.MarketID, + &i.MarketName, + &i.Odd, + &i.OddName, + &i.OddHeader, + &i.OddHandicap, + &i.Status, + &i.Expires, + ) + return i, err } const UpdateCashOut = `-- name: UpdateCashOut :exec diff --git a/internal/domain/bet.go b/internal/domain/bet.go index 6e2d81a..93392cf 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -80,3 +80,82 @@ type CreateBet struct { IsShopBet bool CashoutID string } + +type CreateBetOutcomeReq struct { + EventID int64 `json:"event_id" example:"1"` + OddID int64 `json:"odd_id" example:"1"` + MarketID int64 `json:"market_id" example:"1"` +} + +type CreateBetReq struct { + Outcomes []CreateBetOutcomeReq `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID *int64 `json:"branch_id,omitempty" example:"1"` +} + +type RandomBetReq struct { + BranchID int64 `json:"branch_id,omitempty" example:"1"` +} + +type CreateBetRes struct { + ID int64 `json:"id" example:"1"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CreatedNumber int64 `json:"created_number" example:"2"` + CashedID string `json:"cashed_id" example:"21234"` +} +type BetRes struct { + ID int64 `json:"id" example:"1"` + Outcomes []BetOutcome `json:"outcomes"` + Amount float32 `json:"amount" example:"100.0"` + TotalOdds float32 `json:"total_odds" example:"4.22"` + Status OutcomeStatus `json:"status" example:"1"` + FullName string `json:"full_name" example:"John"` + PhoneNumber string `json:"phone_number" example:"1234567890"` + BranchID int64 `json:"branch_id" example:"2"` + UserID int64 `json:"user_id" example:"2"` + IsShopBet bool `json:"is_shop_bet" example:"false"` + CashedOut bool `json:"cashed_out" example:"false"` + CashedID string `json:"cashed_id" example:"21234"` +} + +func ConvertCreateBet(bet Bet, createdNumber int64) CreateBetRes { + return CreateBetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + CreatedNumber: createdNumber, + CashedID: bet.CashoutID, + } +} + +func ConvertBet(bet GetBet) BetRes { + return BetRes{ + ID: bet.ID, + Amount: bet.Amount.Float32(), + TotalOdds: bet.TotalOdds, + Status: bet.Status, + FullName: bet.FullName, + PhoneNumber: bet.PhoneNumber, + BranchID: bet.BranchID.Value, + UserID: bet.UserID.Value, + Outcomes: bet.Outcomes, + IsShopBet: bet.IsShopBet, + CashedOut: bet.CashedOut, + CashedID: bet.CashoutID, + } +} diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 5ff779f..6788a27 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -225,12 +225,13 @@ func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]do } return result, nil } -func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ +func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { + update, err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ Status: int32(status), ID: id, }) - return err + res := convertDBBetOutcomes(update) + return res, err } func (s *Store) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index d5ea609..2e8cf24 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -13,8 +13,10 @@ type BetStore interface { GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) GetAllBets(ctx context.Context) ([]domain.GetBet, error) GetBetByBranchID(ctx context.Context, BranchID int64) ([]domain.GetBet, error) + GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]domain.BetOutcome, error) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error - UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error + UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) DeleteBet(ctx context.Context, id int64) error } + diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 1a2cb8d..793618d 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -3,21 +3,50 @@ package bet import ( "context" "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log/slog" "math/big" + random "math/rand" + "slices" + "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) type Service struct { - betStore BetStore + betStore BetStore + eventSvc event.Service + prematchSvc odds.Service + walletSvc wallet.Service + branchSvc branch.Service + logger *slog.Logger } -func NewService(betStore BetStore) *Service { +func NewService(betStore BetStore, eventSvc event.Service, prematchSvc odds.Service, walletSvc wallet.Service, branchSvc branch.Service, logger *slog.Logger) *Service { return &Service{ - betStore: betStore, + betStore: betStore, + eventSvc: eventSvc, + prematchSvc: prematchSvc, + walletSvc: walletSvc, + branchSvc: branchSvc, + logger: logger, } } +var ( + ErrEventHasNotEnded = errors.New("Event has not ended yet") + ErrRawOddInvalid = errors.New("Prematch Raw Odd is Invalid") + ErrBranchIDRequired = errors.New("Branch ID required for this role") + ErrOutcomeLimit = errors.New("Too many outcomes on a single bet") +) + func (s *Service) GenerateCashoutID() (string, error) { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" const length int = 13 @@ -33,8 +62,365 @@ func (s *Service) GenerateCashoutID() (string, error) { return string(result), nil } -func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { +func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketID int64, oddID int64) (domain.CreateBetOutcome, error) { + // TODO: Change this when you refactor the database code + eventIDStr := strconv.FormatInt(eventID, 10) + marketIDStr := strconv.FormatInt(marketID, 10) + oddIDStr := strconv.FormatInt(oddID, 10) + event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) + if err != nil { + return domain.CreateBetOutcome{}, err + } + + currentTime := time.Now() + if event.StartTime.Before(currentTime) { + return domain.CreateBetOutcome{}, ErrEventHasNotEnded + } + + odds, err := s.prematchSvc.GetRawOddsByMarketID(ctx, marketIDStr, eventIDStr) + + if err != nil { + return domain.CreateBetOutcome{}, err + } + type rawOddType struct { + ID string + Name string + Odds string + Header string + Handicap string + } + + var selectedOdd rawOddType + var isOddFound bool = false + + for _, raw := range odds.RawOdds { + var rawOdd rawOddType + rawBytes, err := json.Marshal(raw) + err = json.Unmarshal(rawBytes, &rawOdd) + if err != nil { + fmt.Printf("Failed to unmarshal raw odd %v", err) + continue + } + if rawOdd.ID == oddIDStr { + selectedOdd = rawOdd + isOddFound = true + } + } + if !isOddFound { + return domain.CreateBetOutcome{}, ErrRawOddInvalid + } + + parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) + if err != nil { + return domain.CreateBetOutcome{}, err + } + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + if err != nil { + return domain.CreateBetOutcome{}, err + } + newOutcome := domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: sportID, + HomeTeamName: event.HomeTeam, + AwayTeamName: event.AwayTeam, + MarketName: odds.MarketName, + Odd: float32(parsedOdd), + OddName: selectedOdd.Name, + OddHeader: selectedOdd.Header, + OddHandicap: selectedOdd.Handicap, + Expires: event.StartTime, + } + + return newOutcome, nil + +} + +func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role) (domain.CreateBetRes, error) { + // You can move the loop over req.Outcomes and all the business logic here. + + if len(req.Outcomes) > 30 { + return domain.CreateBetRes{}, ErrOutcomeLimit + } + + var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) + var totalOdds float32 = 1 + + for _, outcomeReq := range req.Outcomes { + newOutcome, err := s.GenerateBetOutcome(ctx, outcomeReq.EventID, outcomeReq.MarketID, outcomeReq.OddID) + if err != nil { + return domain.CreateBetRes{}, err + } + totalOdds = totalOdds * float32(newOutcome.Odd) + outcomes = append(outcomes, newOutcome) + } + + // Handle role-specific logic and wallet deduction if needed. + var cashoutID string + cashoutID, err := s.GenerateCashoutID() + + if err != nil { + return domain.CreateBetRes{}, err + } + + newBet := domain.CreateBet{ + Amount: domain.ToCurrency(req.Amount), + TotalOdds: totalOdds, + Status: req.Status, + FullName: req.FullName, + PhoneNumber: req.PhoneNumber, + CashoutID: cashoutID, + } + switch role { + case domain.RoleCashier: + branch, err := s.branchSvc.GetBranchByCashier(ctx, userID) + if err != nil { + return domain.CreateBetRes{}, err + } + // Deduct from wallet: + // TODO: Make this percentage come from the company + var deductedAmount = req.Amount / 10 + err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) + + if err != nil { + return domain.CreateBetRes{}, err + } + newBet.BranchID = domain.ValidInt64{ + Value: branch.ID, + Valid: true, + } + newBet.UserID = domain.ValidInt64{ + Value: userID, + Valid: true, + } + newBet.IsShopBet = true + // bet, err = s.betStore.CreateBet(ctx) + case domain.RoleBranchManager, domain.RoleAdmin, domain.RoleSuperAdmin: + // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company + // If a non cashier wants to create a bet, they will need to provide the Branch ID + if req.BranchID == nil { + return domain.CreateBetRes{}, ErrBranchIDRequired + } + + newBet.BranchID = domain.ValidInt64{ + Value: *req.BranchID, + Valid: true, + } + newBet.UserID = domain.ValidInt64{ + Value: userID, + Valid: true, + } + newBet.IsShopBet = true + case domain.RoleCustomer: + return domain.CreateBetRes{}, fmt.Errorf("Not yet implemented") + default: + return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") + } + + bet, err := s.CreateBet(ctx, newBet) + + if err != nil { + return domain.CreateBetRes{}, err + } + + // Associate outcomes with the bet. + for i := range outcomes { + outcomes[i].BetID = bet.ID + } + rows, err := s.betStore.CreateBetOutcome(ctx, outcomes) + if err != nil { + return domain.CreateBetRes{}, err + } + + res := domain.ConvertCreateBet(bet, rows) + + return res, nil +} + +func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportID, HomeTeam, AwayTeam string, StartTime time.Time) ([]domain.CreateBetOutcome, float32, error) { + + var newOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + + markets, err := s.prematchSvc.GetPrematchOdds(ctx, eventID) + + if err != nil { + s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) + return nil, 0, err + } + + if len(markets) == 0 { + s.logger.Error("empty odds for event", "event id", eventID) + return nil, 0, fmt.Errorf("empty odds or event", "event id", eventID) + } + + var numMarkets = min(5, len(markets)) + var randIndex []int = make([]int, numMarkets) + for i := 0; i < numMarkets; i++ { + // Guarantee that the odd is unique + var newRandMarket int + count := 0 + for { + newRandMarket = random.Intn(len(markets)) + if !slices.Contains(randIndex, newRandMarket) { + break + } + // just in case + if count >= 5 { + s.logger.Warn("market overload", "event id", eventID) + break + } + count++ + } + + randIndex[i] = newRandMarket + + rawOdds := markets[i].RawOdds + randomRawOdd := rawOdds[random.Intn(len(rawOdds))] + + type rawOddType struct { + ID string + Name string + Odds string + Header string + Handicap string + } + + var selectedOdd rawOddType + rawBytes, err := json.Marshal(randomRawOdd) + err = json.Unmarshal(rawBytes, &selectedOdd) + + if err != nil { + fmt.Printf("Failed to unmarshal raw odd %v", err) + continue + } + parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) + if err != nil { + s.logger.Error("Failed to parse odd", "error", err) + continue + } + sportID, err := strconv.ParseInt(sportID, 10, 64) + if err != nil { + s.logger.Error("Failed to get sport id", "error", err) + continue + } + eventID, err := strconv.ParseInt(eventID, 10, 64) + if err != nil { + s.logger.Error("Failed to get event id", "error", err) + continue + } + oddID, err := strconv.ParseInt(selectedOdd.ID, 10, 64) + if err != nil { + s.logger.Error("Failed to get odd id", "error", err) + continue + } + + marketID, err := strconv.ParseInt(markets[i].MarketID, 10, 64) + if err != nil { + s.logger.Error("Failed to get odd id", "error", err) + continue + } + + marketName := markets[i].MarketName + + newOdds = append(newOdds, domain.CreateBetOutcome{ + EventID: eventID, + OddID: oddID, + MarketID: marketID, + SportID: sportID, + HomeTeamName: HomeTeam, + AwayTeamName: AwayTeam, + MarketName: marketName, + Odd: float32(parsedOdd), + OddName: selectedOdd.Name, + OddHeader: selectedOdd.Header, + OddHandicap: selectedOdd.Handicap, + Expires: StartTime, + }) + + totalOdds = totalOdds * float32(parsedOdd) + + } + + if len(newOdds) == 0 { + s.logger.Error("Failed to generate random outcomes") + return nil, 0, nil + } + + return newOdds, totalOdds, nil +} + +func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64) (domain.CreateBetRes, error) { + + // Get a unexpired event id + events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, 5, 0, domain.ValidString{}, domain.ValidString{}) + + if err != nil { + return domain.CreateBetRes{}, err + } + + // Get market and odds for that + var randomOdds []domain.CreateBetOutcome + var totalOdds float32 = 1 + for _, event := range events { + + newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime) + + if err != nil { + s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) + continue + } + + randomOdds = append(randomOdds, newOdds...) + totalOdds = totalOdds * total + + } + if len(randomOdds) == 0 { + s.logger.Error("Failed to generate random outcomes") + return domain.CreateBetRes{}, nil + } + + var cashoutID string + + cashoutID, err = s.GenerateCashoutID() + if err != nil { + return domain.CreateBetRes{}, err + } + + randomNumber := strconv.FormatInt(int64(random.Intn(10)), 10) + newBet := domain.CreateBet{ + Amount: 123, + TotalOdds: totalOdds, + Status: domain.OUTCOME_STATUS_PENDING, + FullName: "test" + randomNumber, + PhoneNumber: randomNumber, + CashoutID: cashoutID, + BranchID: domain.ValidInt64{Valid: true, Value: branchID}, + UserID: domain.ValidInt64{Valid: true, Value: userID}, + } + + bet, err := s.CreateBet(ctx, newBet) + if err != nil { + return domain.CreateBetRes{}, err + } + + for i := range randomOdds { + randomOdds[i].BetID = bet.ID + } + + rows, err := s.betStore.CreateBetOutcome(ctx, randomOdds) + if err != nil { + return domain.CreateBetRes{}, err + } + + res := domain.ConvertCreateBet(bet, rows) + + return res, nil +} + +func (s *Service) CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) { return s.betStore.CreateBet(ctx, bet) } @@ -64,8 +450,43 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return s.betStore.UpdateStatus(ctx, id, status) } +func (s *Service) checkBetOutcomeForBet(ctx context.Context, eventID int64) error { + betOutcomes, err := s.betStore.GetBetOutcomeByEventID(ctx, eventID) + if err != nil { + return err + } + status := domain.OUTCOME_STATUS_PENDING + + for _, betOutcome := range betOutcomes { + // Check if any of them are pending + if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { + return nil + } + + if status == domain.OUTCOME_STATUS_PENDING { + status = betOutcome.Status + } else if status == domain.OUTCOME_STATUS_WIN { + status = betOutcome.Status + } else if status == domain.OUTCOME_STATUS_LOSS { + continue + } + } + + if status != domain.OUTCOME_STATUS_PENDING { + return nil + } + + return s.UpdateStatus(ctx, eventID, status) + +} + func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { - return s.betStore.UpdateBetOutcomeStatus(ctx, id, status) + betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) + if err != nil { + return err + } + return s.checkBetOutcomeForBet(ctx, betOutcome.EventID) + } func (s *Service) DeleteBet(ctx context.Context, id int64) error { diff --git a/internal/services/result/service.go b/internal/services/result/service.go index c959f00..c2ef4b1 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -13,6 +13,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" ) type Service struct { @@ -20,14 +21,16 @@ type Service struct { config *config.Config logger *slog.Logger client *http.Client + betSvc bet.Service } -func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger) *Service { +func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger, betSvc bet.Service) *Service { return &Service{ repo: repo, config: cfg, logger: logger, client: &http.Client{Timeout: 10 * time.Second}, + betSvc: betSvc, } } @@ -85,7 +88,7 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { // continue // } - err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) + _, err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) if err != nil { s.logger.Error("Failed to update bet outcome status", "bet_outcome_id", outcome.ID, "error", err) continue diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 268fbb3..d6952a1 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -1,8 +1,6 @@ package handlers import ( - "encoding/json" - "log/slog" "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -10,88 +8,13 @@ import ( "github.com/gofiber/fiber/v2" ) -type CreateBetOutcomeReq struct { - EventID int64 `json:"event_id" example:"1"` - OddID int64 `json:"odd_id" example:"1"` - MarketID int64 `json:"market_id" example:"1"` -} - -type CreateBetReq struct { - Outcomes []CreateBetOutcomeReq `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID *int64 `json:"branch_id,omitempty" example:"1"` -} - -type CreateBetRes struct { - ID int64 `json:"id" example:"1"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CreatedNumber int64 `json:"created_number" example:"2"` - CashedID string `json:"cashed_id" example:"21234"` -} -type BetRes struct { - ID int64 `json:"id" example:"1"` - Outcomes []domain.BetOutcome `json:"outcomes"` - Amount float32 `json:"amount" example:"100.0"` - TotalOdds float32 `json:"total_odds" example:"4.22"` - Status domain.OutcomeStatus `json:"status" example:"1"` - FullName string `json:"full_name" example:"John"` - PhoneNumber string `json:"phone_number" example:"1234567890"` - BranchID int64 `json:"branch_id" example:"2"` - UserID int64 `json:"user_id" example:"2"` - IsShopBet bool `json:"is_shop_bet" example:"false"` - CashedOut bool `json:"cashed_out" example:"false"` - CashedID string `json:"cashed_id" example:"21234"` -} - -func convertCreateBet(bet domain.Bet, createdNumber int64) CreateBetRes { - return CreateBetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - CreatedNumber: createdNumber, - CashedID: bet.CashoutID, - } -} - -func convertBet(bet domain.GetBet) BetRes { - return BetRes{ - ID: bet.ID, - Amount: bet.Amount.Float32(), - TotalOdds: bet.TotalOdds, - Status: bet.Status, - FullName: bet.FullName, - PhoneNumber: bet.PhoneNumber, - BranchID: bet.BranchID.Value, - UserID: bet.UserID.Value, - Outcomes: bet.Outcomes, - IsShopBet: bet.IsShopBet, - CashedOut: bet.CashedOut, - CashedID: bet.CashoutID, - } -} - // CreateBet godoc // @Summary Create a bet // @Description Creates a bet // @Tags bet // @Accept json // @Produce json -// @Param createBet body CreateBetReq true "Creates bet" +// @Param createBet body domain.CreateBetReq true "Creates bet" // @Success 200 {object} BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -102,7 +25,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) role := c.Locals("role").(domain.Role) - var req CreateBetReq + var req domain.CreateBetReq if err := c.BodyParser(&req); err != nil { h.logger.Error("Failed to parse CreateBet request", "error", err) return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -113,199 +36,52 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - // TODO Validate Outcomes Here and make sure they didn't expire - // Validation for creating tickets - if len(req.Outcomes) > 30 { - return response.WriteJSON(c, fiber.StatusBadRequest, "Too many odds/outcomes selected", nil, nil) - } - var outcomes []domain.CreateBetOutcome = make([]domain.CreateBetOutcome, 0, len(req.Outcomes)) - var totalOdds float32 = 1 - for _, outcome := range req.Outcomes { - eventIDStr := strconv.FormatInt(outcome.EventID, 10) - marketIDStr := strconv.FormatInt(outcome.MarketID, 10) - oddIDStr := strconv.FormatInt(outcome.OddID, 10) - event, err := h.eventSvc.GetUpcomingEventByID(c.Context(), eventIDStr) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid event id", err, nil) - } - - // Checking to make sure the event hasn't already started - // currentTime := time.Now() - // if event.StartTime.Before(currentTime) { - // return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - // } - - odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) - - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid market id", err, nil) - } - type rawOddType struct { - ID string - Name string - Odds string - Header string - Handicap string - } - var selectedOdd rawOddType - var isOddFound bool = false - for _, raw := range odds.RawOdds { - var rawOdd rawOddType - rawBytes, err := json.Marshal(raw) - err = json.Unmarshal(rawBytes, &rawOdd) - if err != nil { - h.logger.Error("Failed to unmarshal raw odd", "error", err) - continue - } - if rawOdd.ID == oddIDStr { - selectedOdd = rawOdd - isOddFound = true - } - } - - if !isOddFound { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid odd id", nil, nil) - } - - parsedOdd, err := strconv.ParseFloat(selectedOdd.Odds, 32) - totalOdds = totalOdds * float32(parsedOdd) - - sportID, err := strconv.ParseInt(event.SportID, 10, 64) - if err != nil { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid sport id", nil, nil) - } - - h.logger.Info("Create Bet", slog.Int64("sportId", sportID)) - - outcomes = append(outcomes, domain.CreateBetOutcome{ - EventID: outcome.EventID, - OddID: outcome.OddID, - MarketID: outcome.MarketID, - SportID: sportID, - HomeTeamName: event.HomeTeam, - AwayTeamName: event.AwayTeam, - MarketName: odds.MarketName, - Odd: float32(parsedOdd), - OddName: selectedOdd.Name, - OddHeader: selectedOdd.Header, - OddHandicap: selectedOdd.Handicap, - Expires: event.StartTime, - }) - } - - // Validating user by role - // Differentiating between offline and online bets - cashoutID, err := h.betSvc.GenerateCashoutID() - if err != nil { - h.logger.Error("CreateBetReq failed, unable to create cashout id") - return response.WriteJSON(c, fiber.StatusInternalServerError, "Invalid request", err, nil) - } - var bet domain.Bet - if role == domain.RoleCashier { - - // Get the branch from the branch ID - branch, err := h.branchSvc.GetBranchByCashier(c.Context(), userID) - if err != nil { - h.logger.Error("CreateBetReq failed, branch id invalid") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - // Deduct a percentage of the amount - // TODO move to service layer. Make it fetch dynamically from company - var deductedAmount = req.Amount / 10 - err = h.walletSvc.DeductFromWallet(c.Context(), branch.WalletID, domain.ToCurrency(deductedAmount)) - - if err != nil { - h.logger.Error("CreateBetReq failed, unable to deduct from WalletID") - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) - } - - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: branch.ID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: false, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else if role == domain.RoleSuperAdmin || role == domain.RoleAdmin || role == domain.RoleBranchManager { - // If a non cashier wants to create a bet, they will need to provide the Branch ID - // TODO: restrict the Branch ID of Admin and Branch Manager to only the branches within their own company - if req.BranchID == nil { - h.logger.Error("CreateBetReq failed, Branch ID is required for this type of user") - return response.WriteJSON(c, fiber.StatusBadRequest, "Branch ID is required for this type of user", nil, nil) - } - // h.logger.Info("Branch ID", slog.Int64("branch_id", *req.BranchID)) - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - BranchID: domain.ValidInt64{ - Value: *req.BranchID, - Valid: true, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: true, - CashoutID: cashoutID, - }) - } else { - // TODO if user is customer, get id from the token then get the wallet id from there and reduce the amount - bet, err = h.betSvc.CreateBet(c.Context(), domain.CreateBet{ - Amount: domain.ToCurrency(req.Amount), - TotalOdds: totalOdds, - Status: req.Status, - FullName: req.FullName, - PhoneNumber: req.PhoneNumber, - - BranchID: domain.ValidInt64{ - Value: 0, - Valid: false, - }, - UserID: domain.ValidInt64{ - Value: userID, - Valid: true, - }, - IsShopBet: false, - CashoutID: cashoutID, - }) - } + res, err := h.betSvc.PlaceBet(c.Context(), req, userID, role) if err != nil { - h.logger.Error("CreateBetReq failed", "error", err) - return response.WriteJSON(c, fiber.StatusInternalServerError, "Internal Server Error", err, nil) + h.logger.Error("PlaceBet failed", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") } - // Updating the bet id for outcomes - for index := range outcomes { - outcomes[index].BetID = bet.ID + return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) + +} + +// RandomBet godoc +// @Summary Generate a random bet +// @Description Generate a random bet +// @Tags bet +// @Accept json +// @Produce json +// @Param createBet body domain.RandomBetReq true "Create Random bet" +// @Success 200 {object} BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /random/bet [post] +func (h *Handler) RandomBet(c *fiber.Ctx) error { + + // Get user_id from middleware + userID := c.Locals("user_id").(int64) + // role := c.Locals("role").(domain.Role) + + var req domain.RandomBetReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse RandomBet request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - rows, err := h.betSvc.CreateBetOutcome(c.Context(), outcomes) + valErrs, ok := h.validator.Validate(c, req) + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + res, err := h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID) if err != nil { - h.logger.Error("CreateBetReq failed to create outcomes", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) + h.logger.Error("Random Bet failed", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } - res := convertCreateBet(bet, rows) - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } @@ -327,9 +103,9 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bets") } - res := make([]BetRes, len(bets)) + res := make([]domain.BetRes, len(bets)) for i, bet := range bets { - res[i] = convertBet(bet) + res[i] = domain.ConvertBet(bet) } return response.WriteJSON(c, fiber.StatusOK, "All bets retrieved successfully", res, nil) @@ -360,7 +136,7 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bet") } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) @@ -392,7 +168,7 @@ func (h *Handler) GetBetByCashoutID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bet", err, nil) } - res := convertBet(bet) + res := domain.ConvertBet(bet) return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index f261c4f..905da0b 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -517,9 +517,9 @@ func (h *Handler) GetBetByBranchID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve bets", err, nil) } - var res []BetRes = make([]BetRes, 0, len(bets)) + var res []domain.BetRes = make([]domain.BetRes, 0, len(bets)) for _, bet := range bets { - res = append(res, convertBet(bet)) + res = append(res, domain.ConvertBet(bet)) } return response.WriteJSON(c, fiber.StatusOK, "Branch Bets Retrieved", res, nil) diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 7b7e22a..0623d62 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -149,7 +149,9 @@ func (a *App) initAppRoutes() { a.fiber.Get("/bet/cashout/:id", a.authMiddleware, h.GetBetByCashoutID) a.fiber.Patch("/bet/:id", a.authMiddleware, h.UpdateCashOut) a.fiber.Delete("/bet/:id", a.authMiddleware, h.DeleteBet) - + + a.fiber.Post("/random/bet", a.authMiddleware, h.RandomBet) + // Wallet a.fiber.Get("/wallet", h.GetAllWallets) a.fiber.Get("/wallet/:id", h.GetWalletByID) @@ -176,6 +178,7 @@ func (a *App) initAppRoutes() { // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + } ///user/profile get From 29642bfd148f70a74bec57ba5969b340d9032723 Mon Sep 17 00:00:00 2001 From: KidusAlemayehu Date: Mon, 12 May 2025 01:58:53 +0300 Subject: [PATCH 03/15] add Dockerfile and docker-compose.yml; update makefile for Docker integration --- Dockerfile | 17 +++++++++++++ compose.db.yaml => docker-compose.yml | 20 ++++++++++++++- makefile | 36 ++++++++++++++++++--------- 3 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 Dockerfile rename compose.db.yaml => docker-compose.yml (66%) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..048901b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Builder stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -ldflags="-s -w" -o ./bin/web ./cmd/main.go + +# Runner stage +FROM alpine:3.21 +WORKDIR /app +COPY .env . +COPY --from=builder /app/bin/web /app/bin/web +RUN apk add --no-cache ca-certificates +EXPOSE ${PORT} +CMD ["/app/bin/web"] \ No newline at end of file diff --git a/compose.db.yaml b/docker-compose.yml similarity index 66% rename from compose.db.yaml rename to docker-compose.yml index 72ab8a7..6ee411f 100644 --- a/compose.db.yaml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: '3.9' + services: postgres: image: postgres:16-alpine @@ -14,6 +16,7 @@ services: interval: 5s timeout: 3s retries: 5 + migrate: image: migrate/migrate volumes: @@ -32,6 +35,21 @@ services: networks: - app + app: + build: + context: . + dockerfile: Dockerfile + ports: + - ${PORT}:8080 + environment: + - DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable + depends_on: + migrate: + condition: service_completed_successfully + networks: + - app + command: ["/app/bin/web"] + networks: app: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/makefile b/makefile index 79017cf..54ac98c 100644 --- a/makefile +++ b/makefile @@ -1,41 +1,53 @@ include .env .PHONY: test test: - @go test ./app + @docker compose exec app go test ./app + .PHONY: coverage coverage: @mkdir -p coverage - @go test -coverprofile=coverage.out ./internal/... - @go tool cover -func=coverage.out -o coverage/coverage.txt + @docker compose exec app go test -coverprofile=coverage.out ./internal/... + @docker compose exec app go tool cover -func=coverage.out -o coverage/coverage.txt + .PHONY: build build: - @go build -ldflags="-s" -o ./bin/web ./ + @docker compose build app + .PHONY: run run: - @echo "Running Go application" - @go run ./cmd/main.go + @docker compose up -d + +.PHONY: stop +stop: + @docker compose down + .PHONY: air air: - @echo "Running air" + @echo "Running air locally (not in Docker)" @air -c .air.toml -.PHONY: migrations/up + +.PHONY: migrations/new migrations/new: @echo 'Creating migration files for DB_URL' @migrate create -seq -ext=.sql -dir=./db/migrations $(name) + .PHONY: migrations/up migrations/up: @echo 'Running up migrations...' - @migrate -path ./db/migrations -database $(DB_URL) up + @docker compose up migrate .PHONY: swagger swagger: @swag init -g cmd/main.go + .PHONY: db-up db-up: - docker compose -f compose.db.yaml up + @docker compose up -d postgres + .PHONY: db-down db-down: - docker compose -f compose.db.yaml down + @docker compose down + .PHONY: sqlc-gen sqlc-gen: - @sqlc generate + @sqlc generate \ No newline at end of file From 8d7911375b082b79c8ea68e4e9c19f8d78710c51 Mon Sep 17 00:00:00 2001 From: KidusAlemayehu Date: Mon, 12 May 2025 02:31:36 +0300 Subject: [PATCH 04/15] refactor Dockerfile and docker-compose.yml for multi-stage builds; update makefile for test execution --- Dockerfile | 2 +- docker-compose.yml | 13 +++++++++++++ makefile | 10 +++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 048901b..6a4fd5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY . . RUN go build -ldflags="-s -w" -o ./bin/web ./cmd/main.go # Runner stage -FROM alpine:3.21 +FROM alpine:3.21 AS runner WORKDIR /app COPY .env . COPY --from=builder /app/bin/web /app/bin/web diff --git a/docker-compose.yml b/docker-compose.yml index 6ee411f..4bbf5e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: build: context: . dockerfile: Dockerfile + target: runner ports: - ${PORT}:8080 environment: @@ -50,6 +51,18 @@ services: - app command: ["/app/bin/web"] + + test: + build: + context: . + dockerfile: Dockerfile + target: builder + volumes: + - .:/app + command: ["tail", "-f", "/dev/null"] + networks: + - app + networks: app: driver: bridge \ No newline at end of file diff --git a/makefile b/makefile index 54ac98c..5b62eb3 100644 --- a/makefile +++ b/makefile @@ -1,13 +1,17 @@ include .env .PHONY: test test: - @docker compose exec app go test ./app + @docker compose up -d test + @docker compose exec test go test ./... + @docker compose stop test .PHONY: coverage coverage: @mkdir -p coverage - @docker compose exec app go test -coverprofile=coverage.out ./internal/... - @docker compose exec app go tool cover -func=coverage.out -o coverage/coverage.txt + @docker compose up -d test + @docker compose exec test sh -c "go test -coverprofile=coverage.out ./internal/... && go tool cover -func=coverage.out -o coverage/coverage.txt" + @docker cp $(shell docker ps -q -f "name=fortunebet-test-1"):/app/coverage ./ || true + @docker compose stop test .PHONY: build build: From 6177b8cf92ff25a9337fa3af6a949ceaee5c15c7 Mon Sep 17 00:00:00 2001 From: KidusAlemayehu Date: Tue, 13 May 2025 16:44:31 +0300 Subject: [PATCH 05/15] refactor notification service to use Gorilla WebSocket; implement WebSocket authentication middleware and notification hub --- internal/services/notfication/port.go | 2 +- internal/services/notfication/service.go | 76 +++++++++---- internal/web_server/app.go | 10 +- internal/web_server/handlers/handlers.go | 4 +- .../handlers/notification_handler.go | 104 +++++++++++++----- internal/web_server/middleware.go | 28 +++++ internal/web_server/routes.go | 2 +- internal/web_server/ws/ws.go | 73 ++++++++++++ 8 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 internal/web_server/ws/ws.go diff --git a/internal/services/notfication/port.go b/internal/services/notfication/port.go index 9fa2f72..23120ee 100644 --- a/internal/services/notfication/port.go +++ b/internal/services/notfication/port.go @@ -4,7 +4,7 @@ import ( "context" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/gofiber/websocket/v2" + "github.com/gorilla/websocket" ) type NotificationStore interface { diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index e21f7da..9c5597e 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -11,12 +11,14 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" afro "github.com/amanuelabay/afrosms-go" - "github.com/gofiber/websocket/v2" + "github.com/gorilla/websocket" ) type Service struct { repo repository.NotificationRepository + Hub *ws.NotificationHub connections sync.Map notificationCh chan *domain.Notification stopCh chan struct{} @@ -24,9 +26,11 @@ type Service struct { logger *slog.Logger } -func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) NotificationStore { +func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service { + hub := ws.NewNotificationHub() svc := &Service{ repo: repo, + Hub: hub, logger: logger, connections: sync.Map{}, notificationCh: make(chan *domain.Notification, 1000), @@ -34,6 +38,7 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi config: cfg, } + go hub.Run() go svc.startWorker() go svc.startRetryWorker() @@ -63,10 +68,18 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not notification = created + if notification.DeliveryChannel == domain.DeliveryChannelInApp { + s.Hub.Broadcast <- map[string]interface{}{ + "type": "CREATED_NOTIFICATION", + "recipient_id": notification.RecipientID, + "payload": notification, + } + } + select { case s.notificationCh <- notification: default: - s.logger.Error("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID) + s.logger.Warn("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID) } return nil @@ -78,6 +91,21 @@ func (s *Service) MarkAsRead(ctx context.Context, notificationID string, recipie s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) return err } + + // count, err := s.repo.CountUnreadNotifications(ctx, recipientID) + // if err != nil { + // s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err) + // return err + // } + + // s.Hub.Broadcast <- map[string]interface{}{ + // "type": "COUNT_NOT_OPENED_NOTIFICATION", + // "recipient_id": recipientID, + // "payload": map[string]int{ + // "not_opened_notifications_count": int(count), + // }, + // } + s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) return nil } @@ -99,7 +127,6 @@ func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *we } func (s *Service) DisconnectWebSocket(recipientID int64) { - s.connections.Delete(recipientID) if conn, loaded := s.connections.LoadAndDelete(recipientID); loaded { conn.(*websocket.Conn).Close() s.logger.Info("[NotificationSvc.DisconnectWebSocket] Disconnected WebSocket", "recipientID", recipientID) @@ -160,21 +187,26 @@ func (s *Service) ListRecipientIDs(ctx context.Context, receiver domain.Notifica func (s *Service) handleNotification(notification *domain.Notification) { ctx := context.Background() - if conn, ok := s.connections.Load(notification.RecipientID); ok { - data, err := notification.ToJSON() + switch notification.DeliveryChannel { + case domain.DeliveryChannelSMS: + err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message) if err != nil { - s.logger.Error("[NotificationSvc.HandleNotification] Failed to serialize notification", "id", notification.ID, "error", err) - return - } - if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err != nil { - s.logger.Error("[NotificationSvc.HandleNotification] Failed to send WebSocket message", "id", notification.ID, "error", err) notification.DeliveryStatus = domain.DeliveryStatusFailed } else { notification.DeliveryStatus = domain.DeliveryStatusSent } - } else { - s.logger.Warn("[NotificationSvc.HandleNotification] No WebSocket connection for recipient", "recipientID", notification.RecipientID) - notification.DeliveryStatus = domain.DeliveryStatusFailed + case domain.DeliveryChannelEmail: + err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message) + if err != nil { + notification.DeliveryStatus = domain.DeliveryStatusFailed + } else { + notification.DeliveryStatus = domain.DeliveryStatusSent + } + default: + if notification.DeliveryChannel != domain.DeliveryChannelInApp { + s.logger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", "channel", notification.DeliveryChannel) + notification.DeliveryStatus = domain.DeliveryStatusFailed + } } if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { @@ -210,13 +242,17 @@ func (s *Service) retryFailedNotifications() { go func(notification *domain.Notification) { for attempt := 0; attempt < 3; attempt++ { time.Sleep(time.Duration(attempt) * time.Second) - if conn, ok := s.connections.Load(notification.RecipientID); ok { - data, err := notification.ToJSON() - if err != nil { - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to serialize notification for retry", "id", notification.ID, "error", err) - continue + if notification.DeliveryChannel == domain.DeliveryChannelSMS { + if err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message); err == nil { + notification.DeliveryStatus = domain.DeliveryStatusSent + if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { + s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) + } + s.logger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", "id", notification.ID) + return } - if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err == nil { + } else if notification.DeliveryChannel == domain.DeliveryChannelEmail { + if err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message); err == nil { notification.DeliveryStatus = domain.DeliveryStatusSent if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) diff --git a/internal/web_server/app.go b/internal/web_server/app.go index f3e50bd..e370f4e 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -29,7 +29,7 @@ import ( type App struct { fiber *fiber.App logger *slog.Logger - NotidicationStore notificationservice.NotificationStore + NotidicationStore *notificationservice.Service referralSvc referralservice.ReferralStore port int authSvc *authentication.Service @@ -61,7 +61,7 @@ func NewApp( transactionSvc *transaction.Service, branchSvc *branch.Service, companySvc *company.Service, - notidicationStore notificationservice.NotificationStore, + notidicationStore *notificationservice.Service, prematchSvc *odds.ServiceImpl, eventSvc event.Service, referralSvc referralservice.ReferralStore, @@ -76,9 +76,9 @@ func NewApp( }) app.Use(cors.New(cors.Config{ - AllowOrigins: "*", // Specify your frontend's origin - AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", // Specify the allowed HTTP methods - AllowHeaders: "Content-Type,Authorization,platform", // Specify the allowed headers + AllowOrigins: "*", + AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", + AllowHeaders: "Content-Type,Authorization,platform", // AllowCredentials: true, })) diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index b5f811d..a72e514 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -22,7 +22,7 @@ import ( type Handler struct { logger *slog.Logger - notificationSvc notificationservice.NotificationStore + notificationSvc *notificationservice.Service userSvc *user.Service referralSvc referralservice.ReferralStore walletSvc *wallet.Service @@ -41,7 +41,7 @@ type Handler struct { func New( logger *slog.Logger, - notificationSvc notificationservice.NotificationStore, + notificationSvc *notificationservice.Service, validator *customvalidator.CustomValidator, walletSvc *wallet.Service, referralSvc referralservice.ReferralStore, diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 9d8ca1a..8c6337b 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -3,53 +3,99 @@ package handlers import ( "context" "encoding/json" + "net" + "net/http" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" "github.com/gofiber/fiber/v2" - "github.com/gofiber/websocket/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gorilla/websocket" + "github.com/valyala/fasthttp/fasthttpadaptor" ) -func (h *Handler) ConnectSocket(c *fiber.Ctx) error { - if !websocket.IsWebSocketUpgrade(c) { - h.logger.Warn("WebSocket upgrade required") - return fiber.ErrUpgradeRequired - } +func hijackHTTP(c *fiber.Ctx) (net.Conn, http.ResponseWriter, error) { + var rw http.ResponseWriter + var conn net.Conn + // This is a trick: fasthttpadaptor gives us the HTTP interfaces + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + return + } + var err error + conn, _, err = hj.Hijack() + if err != nil { + return + } + rw = w + }) + + fasthttpadaptor.NewFastHTTPHandler(handler)(c.Context()) + + if conn == nil || rw == nil { + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection") + } + return conn, rw, nil +} + +func (h *Handler) ConnectSocket(c *fiber.Ctx) error { userID, ok := c.Locals("userID").(int64) if !ok || userID == 0 { h.logger.Error("Invalid user ID in context") - return fiber.NewError(fiber.StatusUnauthorized, "invalid user identification") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") } - c.Locals("allowed", true) + // Convert *fiber.Ctx to *http.Request + req, err := adaptor.ConvertRequest(c, false) + if err != nil { + h.logger.Error("Failed to convert request", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert request") + } - return websocket.New(func(conn *websocket.Conn) { - ctx := context.Background() - logger := h.logger.With("userID", userID, "remoteAddr", conn.RemoteAddr()) + // Create a net.Conn hijacked from the fasthttp context + netConn, rw, err := hijackHTTP(c) + if err != nil { + h.logger.Error("Failed to hijack connection", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection") + } - if err := h.notificationSvc.ConnectWebSocket(ctx, userID, conn); err != nil { - logger.Error("Failed to connect WebSocket", "error", err) - _ = conn.Close() - return - } + // Upgrade the connection using Gorilla's Upgrader + conn, err := ws.Upgrader.Upgrade(rw, req, nil) + if err != nil { + h.logger.Error("WebSocket upgrade failed", "error", err) + netConn.Close() + return fiber.NewError(fiber.StatusInternalServerError, "WebSocket upgrade failed") + } - logger.Info("WebSocket connection established") + client := &ws.Client{ + Conn: conn, + RecipientID: userID, + } - defer func() { - h.notificationSvc.DisconnectWebSocket(userID) - logger.Info("WebSocket connection closed") - _ = conn.Close() - }() + h.notificationSvc.Hub.Register <- client + h.logger.Info("WebSocket connection established", "userID", userID) - for { - if _, _, err := conn.ReadMessage(); err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - logger.Warn("WebSocket unexpected close", "error", err) - } - break + defer func() { + h.notificationSvc.Hub.Unregister <- client + h.logger.Info("WebSocket connection closed", "userID", userID) + conn.Close() + }() + + for { + _, _, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + h.logger.Info("WebSocket closed normally", "userID", userID) + } else { + h.logger.Warn("Unexpected WebSocket closure", "userID", userID, "error", err) } + break } - })(c) + } + + return nil } func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index c7c90d5..63cfe6e 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -71,3 +71,31 @@ func (a *App) CompanyOnly(c *fiber.Ctx) error { } return c.Next() } + +func (a *App) WebsocketAuthMiddleware(c *fiber.Ctx) error { + tokenStr := c.Query("token") + if tokenStr == "" { + a.logger.Error("Missing token in query parameter") + return fiber.NewError(fiber.StatusUnauthorized, "Missing token") + } + + claim, err := jwtutil.ParseJwt(tokenStr, a.JwtConfig.JwtAccessKey) + if err != nil { + if errors.Is(err, jwtutil.ErrExpiredToken) { + a.logger.Error("Token expired") + return fiber.NewError(fiber.StatusUnauthorized, "Token expired") + } + a.logger.Error("Invalid token", "error", err) + return fiber.NewError(fiber.StatusUnauthorized, "Invalid token") + } + + userID := claim.UserId + if userID == 0 { + a.logger.Error("Invalid user ID in token claims") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID") + } + + c.Locals("userID", userID) + a.logger.Info("Authenticated WebSocket connection", "userID", userID) + return c.Next() +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 7b7e22a..6095931 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -169,7 +169,7 @@ func (a *App) initAppRoutes() { a.fiber.Put("/transaction/:id", a.authMiddleware, h.UpdateTransactionVerified) // Notification Routes - a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket) + a.fiber.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) a.fiber.Post("/notifications/create", h.CreateAndSendNotification) diff --git a/internal/web_server/ws/ws.go b/internal/web_server/ws/ws.go new file mode 100644 index 0000000..28fb860 --- /dev/null +++ b/internal/web_server/ws/ws.go @@ -0,0 +1,73 @@ +package ws + +import ( + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" +) + +type Client struct { + Conn *websocket.Conn + RecipientID int64 +} + +type NotificationHub struct { + Clients map[*Client]bool + Broadcast chan interface{} + Register chan *Client + Unregister chan *Client + mu sync.Mutex +} + +func NewNotificationHub() *NotificationHub { + return &NotificationHub{ + Clients: make(map[*Client]bool), + Broadcast: make(chan interface{}, 1000), + Register: make(chan *Client), + Unregister: make(chan *Client), + } +} + +func (h *NotificationHub) Run() { + for { + select { + case client := <-h.Register: + h.mu.Lock() + h.Clients[client] = true + h.mu.Unlock() + log.Printf("Client registered: %d", client.RecipientID) + case client := <-h.Unregister: + h.mu.Lock() + if _, ok := h.Clients[client]; ok { + delete(h.Clients, client) + client.Conn.Close() + } + h.mu.Unlock() + log.Printf("Client unregistered: %d", client.RecipientID) + case message := <-h.Broadcast: + h.mu.Lock() + for client := range h.Clients { + if payload, ok := message.(map[string]interface{}); ok { + if recipient, ok := payload["recipient_id"].(int64); ok && recipient == client.RecipientID { + err := client.Conn.WriteJSON(payload) + if err != nil { + delete(h.Clients, client) + client.Conn.Close() + } + } + } + } + h.mu.Unlock() + } + } +} + +var Upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} From 4a2ae14a64c5a044f8aac2db24a16a660b631993 Mon Sep 17 00:00:00 2001 From: KidusAlemayehu Date: Tue, 13 May 2025 22:43:51 +0300 Subject: [PATCH 06/15] add notification retrieval endpoint; refactor middleware for company role validation --- docker-compose.yml | 9 ++- .../handlers/notification_handler.go | 55 ++++++++++++++++--- internal/web_server/middleware.go | 2 +- internal/web_server/routes.go | 1 + 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4bbf5e6..e1f077d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: postgres: image: postgres:16-alpine @@ -16,6 +14,8 @@ services: interval: 5s timeout: 3s retries: 5 + volumes: + - postgres_data:/var/lib/postgresql/data migrate: image: migrate/migrate @@ -65,4 +65,7 @@ services: networks: app: - driver: bridge \ No newline at end of file + driver: bridge + +volumes: + postgres_data: diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 8c6337b..3d61451 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net" "net/http" + "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" @@ -143,18 +144,18 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - userID, ok := c.Locals("userID").(int64) - if !ok || userID == 0 { - h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context") - return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") - } + // userID, ok := c.Locals("userID").(int64) + // if !ok || userID == 0 { + // h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context") + // return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + // } switch req.DeliveryScheme { case domain.NotificationDeliverySchemeSingle: - if req.Reciever == domain.NotificationRecieverSideCustomer && req.RecipientID != userID { - h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "userID", userID, "recipientID", req.RecipientID) - return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient") - } + // if req.Reciever == domain.NotificationRecieverSideCustomer { + // h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "recipientID", req.RecipientID) + // return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient") + // } notification := &domain.Notification{ ID: "", @@ -223,6 +224,42 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { } } +func (h *Handler) GetNotifications(c *fiber.Ctx) error { + limitStr := c.Query("limit", "10") + offsetStr := c.Query("offset", "0") + + // Convert limit and offset to integers + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid limit value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value") + } + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid offset value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid offset value") + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid user ID in context") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + notifications, err := h.notificationSvc.ListNotifications(context.Background(), userID, limit, offset) + if err != nil { + h.logger.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "notifications": notifications, + "total_count": len(notifications), + "limit": limit, + "offset": offset, + }) +} + func (h *Handler) getAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) { return h.notificationSvc.ListRecipientIDs(ctx, receiver) } diff --git a/internal/web_server/middleware.go b/internal/web_server/middleware.go index 63cfe6e..8e550cb 100644 --- a/internal/web_server/middleware.go +++ b/internal/web_server/middleware.go @@ -44,7 +44,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error { } // Asserting to make sure that there is no company role without a valid company id - if claim.Role != domain.RoleSuperAdmin && !claim.CompanyID.Valid { + if claim.Role != domain.RoleSuperAdmin && claim.Role != domain.RoleCustomer && !claim.CompanyID.Valid { fmt.Println("Company Role without Company ID") return fiber.NewError(fiber.StatusInternalServerError, "Company Role without Company ID") } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 6095931..70fb79a 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -170,6 +170,7 @@ func (a *App) initAppRoutes() { // Notification Routes a.fiber.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) + a.fiber.Get("/notifications", a.authMiddleware, h.GetNotifications) a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) a.fiber.Post("/notifications/create", h.CreateAndSendNotification) From 95fb33c9d481d08f9d1c2bd72850ff74a58c2ea9 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 15 May 2025 01:01:09 +0300 Subject: [PATCH 07/15] fix: result service evaluation issues --- cmd/main.go | 1 + db/migrations/000001_fortune.up.sql | 36 ++- db/query/bet.sql | 8 +- db/query/events.sql | 18 +- db/query/odds.sql | 28 +- docs/docs.go | 289 +++++++++++------- docs/swagger.json | 289 +++++++++++------- docs/swagger.yaml | 200 +++++++----- gen/db/bet.sql.go | 50 ++- gen/db/events.sql.go | 30 +- gen/db/odds.sql.go | 105 ++++--- internal/domain/bet.go | 2 +- internal/domain/common.go | 9 +- internal/domain/league.go | 10 +- internal/domain/oddres.go | 3 +- internal/domain/result.go | 20 ++ internal/domain/sportmarket.go | 3 + internal/repository/bet.go | 14 + internal/repository/event.go | 23 +- internal/repository/odds.go | 51 +++- internal/services/bet/port.go | 2 +- internal/services/bet/service.go | 176 +++++++---- internal/services/event/port.go | 2 +- internal/services/event/service.go | 43 ++- internal/services/odds/port.go | 2 + internal/services/odds/service.go | 72 +++-- internal/services/result/eval.go | 265 ++++++++++++---- internal/services/result/football_test.go | 30 ++ internal/services/result/service.go | 91 ++++-- internal/services/result/service_test.go | 1 - internal/services/ticket/service.go | 4 + internal/web_server/cron.go | 67 ++-- internal/web_server/handlers/bet_handler.go | 57 +++- .../web_server/handlers/branch_handler.go | 2 +- internal/web_server/handlers/prematch.go | 41 ++- makefile | 2 +- 36 files changed, 1425 insertions(+), 621 deletions(-) create mode 100644 internal/services/result/football_test.go delete mode 100644 internal/services/result/service_test.go diff --git a/cmd/main.go b/cmd/main.go index 364cb70..fa4b9fc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -86,6 +86,7 @@ func main() { virtualGameSvc := virtualgameservice.New(vitualGameRepo, *walletSvc, store, cfg, logger) httpserver.StartDataFetchingCrons(eventSvc, oddsSvc, resultSvc) + httpserver.StartTicketCrons(*ticketSvc) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 3cf6d3e..c351619 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -340,15 +340,43 @@ INSERT INTO users ( suspended_at, suspended ) +VALUES ( + 'Test', + 'Admin', + 'test.admin@gmail.com', + '0911111111', + crypt('password123', gen_salt('bf'))::bytea, + 'admin', + TRUE, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, + NULL, + FALSE + ); +INSERT INTO users ( + first_name, + last_name, + email, + phone_number, + password, + role, + email_verified, + phone_verified, + created_at, + updated_at, + suspended_at, + suspended + ) VALUES ( 'Samuel', 'Tariku', 'cybersamt@gmail.com', - NULL, + '0911111111', crypt('password@123', gen_salt('bf'))::bytea, 'super_admin', TRUE, - FALSE, + TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, @@ -372,11 +400,11 @@ VALUES ( 'Kirubel', 'Kibru', 'kirubeljkl679 @gmail.com', - NULL, + '0911111111', crypt('password@123', gen_salt('bf'))::bytea, 'super_admin', TRUE, - FALSE, + TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL, diff --git a/db/query/bet.sql b/db/query/bet.sql index aed3aa4..61a3d02 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -62,6 +62,10 @@ WHERE branch_id = $1; SELECT * FROM bet_outcomes WHERE event_id = $1; +-- name: GetBetOutcomeByBetID :many +SELECT * +FROM bet_outcomes +WHERE bet_id = $1; -- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, @@ -74,9 +78,9 @@ WHERE id = $2 RETURNING *; -- name: UpdateStatus :exec UPDATE bets -SET status = $2, +SET status = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = $1; +WHERE id = $2; -- name: DeleteBet :exec DELETE FROM bets WHERE id = $1; diff --git a/db/query/events.sql b/db/query/events.sql index 4109c44..1e40107 100644 --- a/db/query/events.sql +++ b/db/query/events.sql @@ -196,15 +196,23 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $3 - OR $3 IS NULL + league_id = sqlc.narg('league_id') + OR sqlc.narg('league_id') IS NULL ) AND ( - sport_id = $4 - OR $4 IS NULL + sport_id = sqlc.narg('sport_id') + OR sqlc.narg('sport_id') IS NULL + ) + AND ( + start_time < sqlc.narg('last_start_time') + OR sqlc.narg('last_start_time') IS NULL + ) + AND ( + start_time > sqlc.narg('first_start_time') + OR sqlc.narg('first_start_time') IS NULL ) ORDER BY start_time ASC -LIMIT $1 OFFSET $2; +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetUpcomingByID :one SELECT id, sport_id, diff --git a/db/query/odds.sql b/db/query/odds.sql index 908a445..9de17b3 100644 --- a/db/query/odds.sql +++ b/db/query/odds.sql @@ -94,23 +94,17 @@ WHERE market_id = $1 AND fi = $2 AND is_active = true AND source = 'b365api'; - -- name: GetPrematchOddsByUpcomingID :many -SELECT o.event_id, - o.fi, - o.market_type, - o.market_name, - o.market_category, - o.market_id, - o.name, - o.handicap, - o.odds_value, - o.section, - o.category, - o.raw_odds, - o.fetched_at, - o.source, - o.is_active +SELECT o.* +FROM odds o + JOIN events e ON o.fi = e.id +WHERE e.id = $1 + AND e.is_live = false + AND e.status = 'upcoming' + AND o.is_active = true + AND o.source = 'b365api'; +-- name: GetPaginatedPrematchOddsByUpcomingID :many +SELECT o.* FROM odds o JOIN events e ON o.fi = e.id WHERE e.id = $1 @@ -118,4 +112,4 @@ WHERE e.id = $1 AND e.status = 'upcoming' AND o.is_active = true AND o.source = 'b365api' -LIMIT $2 OFFSET $3; \ No newline at end of file +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 72da21c..2a56dac 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -304,7 +304,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -341,7 +341,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateBetReq" + "$ref": "#/definitions/domain.CreateBetReq" } } ], @@ -349,7 +349,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -393,7 +393,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -437,7 +437,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -786,7 +786,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -1915,6 +1915,52 @@ const docTemplate = `{ } } }, + "/random/bet": { + "post": { + "description": "Generate a random bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Generate a random bet", + "parameters": [ + { + "description": "Create Random bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RandomBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/referral/settings": { "get": { "security": [ @@ -3386,6 +3432,117 @@ const docTemplate = `{ } } }, + "domain.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "cashed_id": { + "type": "string", + "example": "21234" + }, + "cashed_out": { + "type": "boolean", + "example": false + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.BetOutcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "domain.CreateBetOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 1 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateBetOutcomeReq" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + } + } + }, "domain.Odd": { "type": "object", "properties": { @@ -3501,6 +3658,15 @@ const docTemplate = `{ } } }, + "domain.RandomBetReq": { + "type": "object", + "properties": { + "branch_id": { + "type": "integer", + "example": 1 + } + } + }, "domain.RawOddsByMarketID": { "type": "object", "properties": { @@ -3757,65 +3923,6 @@ const docTemplate = `{ } } }, - "handlers.BetRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 2 - }, - "cashed_id": { - "type": "string", - "example": "21234" - }, - "cashed_out": { - "type": "boolean", - "example": false - }, - "full_name": { - "type": "string", - "example": "John" - }, - "id": { - "type": "integer", - "example": 1 - }, - "is_shop_bet": { - "type": "boolean", - "example": false - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.BetOutcome" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - }, - "total_odds": { - "type": "number", - "example": 4.22 - }, - "user_id": { - "type": "integer", - "example": 2 - } - } - }, "handlers.BranchDetailRes": { "type": "object", "properties": { @@ -3977,58 +4084,6 @@ const docTemplate = `{ } } }, - "handlers.CreateBetOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateBetReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 1 - }, - "full_name": { - "type": "string", - "example": "John" - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateBetOutcomeReq" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - } - } - }, "handlers.CreateBranchOperationReq": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 123e78f..66fa0cd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -296,7 +296,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -333,7 +333,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.CreateBetReq" + "$ref": "#/definitions/domain.CreateBetReq" } } ], @@ -341,7 +341,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -385,7 +385,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -429,7 +429,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } }, "400": { @@ -778,7 +778,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/handlers.BetRes" + "$ref": "#/definitions/domain.BetRes" } } }, @@ -1907,6 +1907,52 @@ } } }, + "/random/bet": { + "post": { + "description": "Generate a random bet", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "bet" + ], + "summary": "Generate a random bet", + "parameters": [ + { + "description": "Create Random bet", + "name": "createBet", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.RandomBetReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.BetRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/referral/settings": { "get": { "security": [ @@ -3378,6 +3424,117 @@ } } }, + "domain.BetRes": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 2 + }, + "cashed_id": { + "type": "string", + "example": "21234" + }, + "cashed_out": { + "type": "boolean", + "example": false + }, + "full_name": { + "type": "string", + "example": "John" + }, + "id": { + "type": "integer", + "example": 1 + }, + "is_shop_bet": { + "type": "boolean", + "example": false + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.BetOutcome" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + }, + "total_odds": { + "type": "number", + "example": 4.22 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "domain.CreateBetOutcomeReq": { + "type": "object", + "properties": { + "event_id": { + "type": "integer", + "example": 1 + }, + "market_id": { + "type": "integer", + "example": 1 + }, + "odd_id": { + "type": "integer", + "example": 1 + } + } + }, + "domain.CreateBetReq": { + "type": "object", + "properties": { + "amount": { + "type": "number", + "example": 100 + }, + "branch_id": { + "type": "integer", + "example": 1 + }, + "full_name": { + "type": "string", + "example": "John" + }, + "outcomes": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.CreateBetOutcomeReq" + } + }, + "phone_number": { + "type": "string", + "example": "1234567890" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/domain.OutcomeStatus" + } + ], + "example": 1 + } + } + }, "domain.Odd": { "type": "object", "properties": { @@ -3493,6 +3650,15 @@ } } }, + "domain.RandomBetReq": { + "type": "object", + "properties": { + "branch_id": { + "type": "integer", + "example": 1 + } + } + }, "domain.RawOddsByMarketID": { "type": "object", "properties": { @@ -3749,65 +3915,6 @@ } } }, - "handlers.BetRes": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 2 - }, - "cashed_id": { - "type": "string", - "example": "21234" - }, - "cashed_out": { - "type": "boolean", - "example": false - }, - "full_name": { - "type": "string", - "example": "John" - }, - "id": { - "type": "integer", - "example": 1 - }, - "is_shop_bet": { - "type": "boolean", - "example": false - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/domain.BetOutcome" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - }, - "total_odds": { - "type": "number", - "example": 4.22 - }, - "user_id": { - "type": "integer", - "example": 2 - } - } - }, "handlers.BranchDetailRes": { "type": "object", "properties": { @@ -3969,58 +4076,6 @@ } } }, - "handlers.CreateBetOutcomeReq": { - "type": "object", - "properties": { - "event_id": { - "type": "integer", - "example": 1 - }, - "market_id": { - "type": "integer", - "example": 1 - }, - "odd_id": { - "type": "integer", - "example": 1 - } - } - }, - "handlers.CreateBetReq": { - "type": "object", - "properties": { - "amount": { - "type": "number", - "example": 100 - }, - "branch_id": { - "type": "integer", - "example": 1 - }, - "full_name": { - "type": "string", - "example": "John" - }, - "outcomes": { - "type": "array", - "items": { - "$ref": "#/definitions/handlers.CreateBetOutcomeReq" - } - }, - "phone_number": { - "type": "string", - "example": "1234567890" - }, - "status": { - "allOf": [ - { - "$ref": "#/definitions/domain.OutcomeStatus" - } - ], - "example": 1 - } - } - }, "handlers.CreateBranchOperationReq": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e76a984..fee0fad 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -48,6 +48,82 @@ definitions: - $ref: '#/definitions/domain.OutcomeStatus' example: 1 type: object + domain.BetRes: + properties: + amount: + example: 100 + type: number + branch_id: + example: 2 + type: integer + cashed_id: + example: "21234" + type: string + cashed_out: + example: false + type: boolean + full_name: + example: John + type: string + id: + example: 1 + type: integer + is_shop_bet: + example: false + type: boolean + outcomes: + items: + $ref: '#/definitions/domain.BetOutcome' + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.OutcomeStatus' + example: 1 + total_odds: + example: 4.22 + type: number + user_id: + example: 2 + type: integer + type: object + domain.CreateBetOutcomeReq: + properties: + event_id: + example: 1 + type: integer + market_id: + example: 1 + type: integer + odd_id: + example: 1 + type: integer + type: object + domain.CreateBetReq: + properties: + amount: + example: 100 + type: number + branch_id: + example: 1 + type: integer + full_name: + example: John + type: string + outcomes: + items: + $ref: '#/definitions/domain.CreateBetOutcomeReq' + type: array + phone_number: + example: "1234567890" + type: string + status: + allOf: + - $ref: '#/definitions/domain.OutcomeStatus' + example: 1 + type: object domain.Odd: properties: category: @@ -130,6 +206,12 @@ definitions: description: BET, WIN, REFUND, JACKPOT_WIN type: string type: object + domain.RandomBetReq: + properties: + branch_id: + example: 1 + type: integer + type: object domain.RawOddsByMarketID: properties: fetched_at: @@ -309,47 +391,6 @@ definitions: updated_at: type: string type: object - handlers.BetRes: - properties: - amount: - example: 100 - type: number - branch_id: - example: 2 - type: integer - cashed_id: - example: "21234" - type: string - cashed_out: - example: false - type: boolean - full_name: - example: John - type: string - id: - example: 1 - type: integer - is_shop_bet: - example: false - type: boolean - outcomes: - items: - $ref: '#/definitions/domain.BetOutcome' - type: array - phone_number: - example: "1234567890" - type: string - status: - allOf: - - $ref: '#/definitions/domain.OutcomeStatus' - example: 1 - total_odds: - example: 4.22 - type: number - user_id: - example: 2 - type: integer - type: object handlers.BranchDetailRes: properties: branch_manager_id: @@ -465,41 +506,6 @@ definitions: example: "1234567890" type: string type: object - handlers.CreateBetOutcomeReq: - properties: - event_id: - example: 1 - type: integer - market_id: - example: 1 - type: integer - odd_id: - example: 1 - type: integer - type: object - handlers.CreateBetReq: - properties: - amount: - example: 100 - type: number - branch_id: - example: 1 - type: integer - full_name: - example: John - type: string - outcomes: - items: - $ref: '#/definitions/handlers.CreateBetOutcomeReq' - type: array - phone_number: - example: "1234567890" - type: string - status: - allOf: - - $ref: '#/definitions/domain.OutcomeStatus' - example: 1 - type: object handlers.CreateBranchOperationReq: properties: branch_id: @@ -1320,7 +1326,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' type: array "400": description: Bad Request @@ -1343,14 +1349,14 @@ paths: name: createBet required: true schema: - $ref: '#/definitions/handlers.CreateBetReq' + $ref: '#/definitions/domain.CreateBetReq' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -1407,7 +1413,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -1470,7 +1476,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' "400": description: Bad Request schema: @@ -1639,7 +1645,7 @@ paths: description: OK schema: items: - $ref: '#/definitions/handlers.BetRes' + $ref: '#/definitions/domain.BetRes' type: array "400": description: Bad Request @@ -2385,6 +2391,36 @@ paths: summary: Retrieve raw odds by Market ID tags: - prematch + /random/bet: + post: + consumes: + - application/json + description: Generate a random bet + parameters: + - description: Create Random bet + in: body + name: createBet + required: true + schema: + $ref: '#/definitions/domain.RandomBetReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.BetRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Generate a random bet + tags: + - bet /referral/settings: get: consumes: diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index 0f10df6..823fb43 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -243,6 +243,48 @@ func (q *Queries) GetBetByID(ctx context.Context, id int64) (BetWithOutcome, err return i, err } +const GetBetOutcomeByBetID = `-- name: GetBetOutcomeByBetID :many +SELECT id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires +FROM bet_outcomes +WHERE bet_id = $1 +` + +func (q *Queries) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]BetOutcome, error) { + rows, err := q.db.Query(ctx, GetBetOutcomeByBetID, betID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BetOutcome + for rows.Next() { + var i BetOutcome + if err := rows.Scan( + &i.ID, + &i.BetID, + &i.SportID, + &i.EventID, + &i.OddID, + &i.HomeTeamName, + &i.AwayTeamName, + &i.MarketID, + &i.MarketName, + &i.Odd, + &i.OddName, + &i.OddHeader, + &i.OddHandicap, + &i.Status, + &i.Expires, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetBetOutcomeByEventID = `-- name: GetBetOutcomeByEventID :many SELECT id, bet_id, sport_id, event_id, odd_id, home_team_name, away_team_name, market_id, market_name, odd, odd_name, odd_header, odd_handicap, status, expires FROM bet_outcomes @@ -339,17 +381,17 @@ func (q *Queries) UpdateCashOut(ctx context.Context, arg UpdateCashOutParams) er const UpdateStatus = `-- name: UpdateStatus :exec UPDATE bets -SET status = $2, +SET status = $1, updated_at = CURRENT_TIMESTAMP -WHERE id = $1 +WHERE id = $2 ` type UpdateStatusParams struct { - ID int64 `json:"id"` Status int32 `json:"status"` + ID int64 `json:"id"` } func (q *Queries) UpdateStatus(ctx context.Context, arg UpdateStatusParams) error { - _, err := q.db.Exec(ctx, UpdateStatus, arg.ID, arg.Status) + _, err := q.db.Exec(ctx, UpdateStatus, arg.Status, arg.ID) return err } diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 94315a7..6c1e083 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -201,22 +201,32 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $3 + league_id = $1 + OR $1 IS NULL + ) + AND ( + sport_id = $2 + OR $2 IS NULL + ) + AND ( + start_time < $3 OR $3 IS NULL ) AND ( - sport_id = $4 + start_time > $4 OR $4 IS NULL ) ORDER BY start_time ASC -LIMIT $1 OFFSET $2 +LIMIT $6 OFFSET $5 ` type GetPaginatedUpcomingEventsParams struct { - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Text `json:"league_id"` + SportID pgtype.Text `json:"sport_id"` + LastStartTime pgtype.Timestamp `json:"last_start_time"` + FirstStartTime pgtype.Timestamp `json:"first_start_time"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetPaginatedUpcomingEventsRow struct { @@ -240,10 +250,12 @@ type GetPaginatedUpcomingEventsRow struct { func (q *Queries) GetPaginatedUpcomingEvents(ctx context.Context, arg GetPaginatedUpcomingEventsParams) ([]GetPaginatedUpcomingEventsRow, error) { rows, err := q.db.Query(ctx, GetPaginatedUpcomingEvents, - arg.Limit, - arg.Offset, arg.LeagueID, arg.SportID, + arg.LastStartTime, + arg.FirstStartTime, + arg.Offset, + arg.Limit, ) if err != nil { return nil, err diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go index 3f920f4..3d92299 100644 --- a/gen/db/odds.sql.go +++ b/gen/db/odds.sql.go @@ -86,6 +86,61 @@ func (q *Queries) GetALLPrematchOdds(ctx context.Context) ([]GetALLPrematchOddsR return items, nil } +const GetPaginatedPrematchOddsByUpcomingID = `-- name: GetPaginatedPrematchOddsByUpcomingID :many +SELECT o.id, o.event_id, o.fi, o.market_type, o.market_name, o.market_category, o.market_id, o.name, o.handicap, o.odds_value, o.section, o.category, o.raw_odds, o.fetched_at, o.source, o.is_active +FROM odds o + JOIN events e ON o.fi = e.id +WHERE e.id = $1 + AND e.is_live = false + AND e.status = 'upcoming' + AND o.is_active = true + AND o.source = 'b365api' +LIMIT $3 OFFSET $2 +` + +type GetPaginatedPrematchOddsByUpcomingIDParams struct { + ID string `json:"id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +func (q *Queries) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, arg GetPaginatedPrematchOddsByUpcomingIDParams) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPaginatedPrematchOddsByUpcomingID, arg.ID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Odd + for rows.Next() { + var i Odd + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Fi, + &i.MarketType, + &i.MarketName, + &i.MarketCategory, + &i.MarketID, + &i.Name, + &i.Handicap, + &i.OddsValue, + &i.Section, + &i.Category, + &i.RawOdds, + &i.FetchedAt, + &i.Source, + &i.IsActive, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetPrematchOdds = `-- name: GetPrematchOdds :many SELECT event_id, fi, @@ -162,21 +217,7 @@ func (q *Queries) GetPrematchOdds(ctx context.Context) ([]GetPrematchOddsRow, er } const GetPrematchOddsByUpcomingID = `-- name: GetPrematchOddsByUpcomingID :many -SELECT o.event_id, - o.fi, - o.market_type, - o.market_name, - o.market_category, - o.market_id, - o.name, - o.handicap, - o.odds_value, - o.section, - o.category, - o.raw_odds, - o.fetched_at, - o.source, - o.is_active +SELECT o.id, o.event_id, o.fi, o.market_type, o.market_name, o.market_category, o.market_id, o.name, o.handicap, o.odds_value, o.section, o.category, o.raw_odds, o.fetched_at, o.source, o.is_active FROM odds o JOIN events e ON o.fi = e.id WHERE e.id = $1 @@ -184,43 +225,19 @@ WHERE e.id = $1 AND e.status = 'upcoming' AND o.is_active = true AND o.source = 'b365api' -LIMIT $2 OFFSET $3 ` -type GetPrematchOddsByUpcomingIDParams struct { - ID string `json:"id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` -} - -type GetPrematchOddsByUpcomingIDRow struct { - EventID pgtype.Text `json:"event_id"` - Fi pgtype.Text `json:"fi"` - MarketType string `json:"market_type"` - MarketName pgtype.Text `json:"market_name"` - MarketCategory pgtype.Text `json:"market_category"` - MarketID pgtype.Text `json:"market_id"` - Name pgtype.Text `json:"name"` - Handicap pgtype.Text `json:"handicap"` - OddsValue pgtype.Float8 `json:"odds_value"` - Section string `json:"section"` - Category pgtype.Text `json:"category"` - RawOdds []byte `json:"raw_odds"` - FetchedAt pgtype.Timestamp `json:"fetched_at"` - Source pgtype.Text `json:"source"` - IsActive pgtype.Bool `json:"is_active"` -} - -func (q *Queries) GetPrematchOddsByUpcomingID(ctx context.Context, arg GetPrematchOddsByUpcomingIDParams) ([]GetPrematchOddsByUpcomingIDRow, error) { - rows, err := q.db.Query(ctx, GetPrematchOddsByUpcomingID, arg.ID, arg.Limit, arg.Offset) +func (q *Queries) GetPrematchOddsByUpcomingID(ctx context.Context, id string) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPrematchOddsByUpcomingID, id) if err != nil { return nil, err } defer rows.Close() - var items []GetPrematchOddsByUpcomingIDRow + var items []Odd for rows.Next() { - var i GetPrematchOddsByUpcomingIDRow + var i Odd if err := rows.Scan( + &i.ID, &i.EventID, &i.Fi, &i.MarketType, diff --git a/internal/domain/bet.go b/internal/domain/bet.go index 93392cf..e8f4ee2 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -97,7 +97,7 @@ type CreateBetReq struct { } type RandomBetReq struct { - BranchID int64 `json:"branch_id,omitempty" example:"1"` + BranchID int64 `json:"branch_id" validate:"required" example:"1"` } type CreateBetRes struct { diff --git a/internal/domain/common.go b/internal/domain/common.go index f5969d9..14323a4 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -1,6 +1,9 @@ package domain -import "fmt" +import ( + "fmt" + "time" +) type ValidInt64 struct { Value int64 @@ -11,6 +14,10 @@ type ValidString struct { Value string Valid bool } +type ValidTime struct { + Value time.Time + Valid bool +} type ValidBool struct { Value bool Valid bool diff --git a/internal/domain/league.go b/internal/domain/league.go index f05914a..8f63445 100644 --- a/internal/domain/league.go +++ b/internal/domain/league.go @@ -12,9 +12,8 @@ var SupportedLeagues = []int64{ 10041957, //UEFA Europa League 10079560, //UEFA Conference League 10047168, // US MLS - + 10044469, // Ethiopian Premier League 10050282, //UEFA Nations League - 10040795, //EuroLeague 10043156, //England FA Cup 10042103, //France Cup @@ -26,5 +25,12 @@ var SupportedLeagues = []int64{ // Basketball 173998768, //NBA + 10041830, //NBA + + // Ice Hockey + 10037477, //NHL + 10037447, //AHL + 10069385, //IIHF World Championship + 10040795, //EuroLeague } diff --git a/internal/domain/oddres.go b/internal/domain/oddres.go index 8c2707d..48540f0 100644 --- a/internal/domain/oddres.go +++ b/internal/domain/oddres.go @@ -12,8 +12,9 @@ type OddsSection struct { Sp map[string]OddsMarket `json:"sp"` } +// The Market ID for the json data can be either string / int which is causing problems when UnMarshalling type OddsMarket struct { - ID json.Number `json:"id"` + ID json.RawMessage `json:"id"` Name string `json:"name"` Odds []json.RawMessage `json:"odds"` Header string `json:"header,omitempty"` diff --git a/internal/domain/result.go b/internal/domain/result.go index 44861d2..fc3a621 100644 --- a/internal/domain/result.go +++ b/internal/domain/result.go @@ -43,4 +43,24 @@ const ( OUTCOME_STATUS_LOSS OutcomeStatus = 2 OUTCOME_STATUS_VOID OutcomeStatus = 3 //Give Back OUTCOME_STATUS_HALF OutcomeStatus = 4 //Half Win and Half Given Back + OUTCOME_STATUS_ERROR OutcomeStatus = 5 //Half Win and Half Given Back ) + +func (o *OutcomeStatus) String() string { + switch *o { + case OUTCOME_STATUS_PENDING: + return "PENDING" + case OUTCOME_STATUS_WIN: + return "WIN" + case OUTCOME_STATUS_LOSS: + return "LOSS" + case OUTCOME_STATUS_VOID: + return "VOID" + case OUTCOME_STATUS_HALF: + return "HALF" + case OUTCOME_STATUS_ERROR: + return "ERROR" + default: + return "UNKNOWN" + } +} diff --git a/internal/domain/sportmarket.go b/internal/domain/sportmarket.go index b6fde09..360afee 100644 --- a/internal/domain/sportmarket.go +++ b/internal/domain/sportmarket.go @@ -9,12 +9,15 @@ const ( FOOTBALL_CORRECT_SCORE FootballMarket = 43 //"correct_score" FOOTBALL_ASIAN_HANDICAP FootballMarket = 938 //"asian_handicap" FOOTBALL_GOAL_LINE FootballMarket = 10143 //"goal_line" + FOOTBALL_HALF_TIME_RESULT FootballMarket = 1579 //"half_time_result" FOOTBALL_FIRST_HALF_ASIAN_HANDICAP FootballMarket = 50137 //"1st_half_asian_handicap" FOOTBALL_FIRST_HALF_GOAL_LINE FootballMarket = 50136 //"1st_half_goal_line" FOOTBALL_FIRST_TEAM_TO_SCORE FootballMarket = 1178 //"first_team_to_score" FOOTBALL_GOALS_ODD_EVEN FootballMarket = 10111 //"goals_odd_even" FOOTBALL_DRAW_NO_BET FootballMarket = 10544 //"draw_no_bet" + + ) type BasketBallMarket int64 diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 6788a27..81a501c 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -2,6 +2,7 @@ package repository import ( "context" + // "fmt" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" @@ -225,6 +226,19 @@ func (s *Store) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]do } return result, nil } + +func (s *Store) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) { + outcomes, err := s.queries.GetBetOutcomeByBetID(ctx, betID) + if err != nil { + return nil, nil + } + var result []domain.BetOutcome = make([]domain.BetOutcome, 0, len(outcomes)) + + for _, outcome := range outcomes { + result = append(result, convertDBBetOutcomes(outcome)) + } + return result, nil +} func (s *Store) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { update, err := s.queries.UpdateBetOutcomeStatus(ctx, dbgen.UpdateBetOutcomeStatusParams{ Status: int32(status), diff --git a/internal/repository/event.go b/internal/repository/event.go index 630cd39..895a963 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -117,7 +117,8 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcoming return upcomingEvents, nil } -func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) { +func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { + events, err := s.queries.GetPaginatedUpcomingEvents(ctx, dbgen.GetPaginatedUpcomingEventsParams{ LeagueID: pgtype.Text{ String: leagueID.Value, @@ -127,8 +128,22 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, off String: sportID.Value, Valid: sportID.Valid, }, - Limit: limit, - Offset: offset * limit, + Limit: pgtype.Int4{ + Int32: int32(limit.Value), + Valid: limit.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(offset.Value), + Valid: offset.Valid, + }, + FirstStartTime: pgtype.Timestamp{ + Time: firstStartTime.Value.UTC(), + Valid: firstStartTime.Valid, + }, + LastStartTime: pgtype.Timestamp{ + Time: lastStartTime.Value.UTC(), + Valid: lastStartTime.Valid, + }, }) if err != nil { @@ -167,7 +182,7 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, off return nil, 0, err } - numberOfPages := math.Ceil(float64(totalCount) / float64(limit)) + numberOfPages := math.Ceil(float64(totalCount) / float64(limit.Value)) return upcomingEvents, int64(numberOfPages), nil } func (s *Store) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) { diff --git a/internal/repository/odds.go b/internal/repository/odds.go index 31810f5..fd20d1c 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -205,15 +205,54 @@ func (s *Store) GetRawOddsByMarketID(ctx context.Context, rawOddsID string, upco FetchedAt: odds.FetchedAt.Time, }, nil } +func (s *Store) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit domain.ValidInt64, offset domain.ValidInt64) ([]domain.Odd, error) { + odds, err := s.queries.GetPaginatedPrematchOddsByUpcomingID(ctx, dbgen.GetPaginatedPrematchOddsByUpcomingIDParams{ + ID: upcomingID, + Limit: pgtype.Int4{ + Int32: int32(limit.Value), + Valid: limit.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(offset.Value), + Valid: offset.Valid, + }, + }) + if err != nil { + return nil, err + } + // Map the results to domain.Odd + domainOdds := make([]domain.Odd, len(odds)) + for i, odd := range odds { + var rawOdds []domain.RawMessage + if err := json.Unmarshal(odd.RawOdds, &rawOdds); err != nil { + rawOdds = nil + } -func (s *Store) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset int32) ([]domain.Odd, error) { - params := dbgen.GetPrematchOddsByUpcomingIDParams{ - ID: upcomingID, - Limit: limit, - Offset: offset, + domainOdds[i] = domain.Odd{ + EventID: odd.EventID.String, + Fi: odd.Fi.String, + MarketType: odd.MarketType, + MarketName: odd.MarketName.String, + MarketCategory: odd.MarketCategory.String, + MarketID: odd.MarketID.String, + Name: odd.Name.String, + Handicap: odd.Handicap.String, + OddsValue: odd.OddsValue.Float64, + Section: odd.Section, + Category: odd.Category.String, + RawOdds: rawOdds, + FetchedAt: odd.FetchedAt.Time, + Source: odd.Source.String, + IsActive: odd.IsActive.Bool, + } } - odds, err := s.queries.GetPrematchOddsByUpcomingID(ctx, params) + return domainOdds, nil +} + +func (s *Store) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) { + + odds, err := s.queries.GetPrematchOddsByUpcomingID(ctx, upcomingID) if err != nil { return nil, err } diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index 2e8cf24..cdd1ea0 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -14,9 +14,9 @@ type BetStore interface { GetAllBets(ctx context.Context) ([]domain.GetBet, error) GetBetByBranchID(ctx context.Context, BranchID int64) ([]domain.GetBet, error) GetBetOutcomeByEventID(ctx context.Context, eventID int64) ([]domain.BetOutcome, error) + GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) DeleteBet(ctx context.Context, id int64) error } - diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 793618d..5bc392d 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -9,7 +9,6 @@ import ( "log/slog" "math/big" random "math/rand" - "slices" "strconv" "time" @@ -20,6 +19,12 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) +var ( + ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") + ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") + ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") +) + type Service struct { betStore BetStore eventSvc event.Service @@ -239,12 +244,12 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return res, nil } -func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportID, HomeTeam, AwayTeam string, StartTime time.Time) ([]domain.CreateBetOutcome, float32, error) { +func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportID, HomeTeam, AwayTeam string, StartTime time.Time, numMarkets int) ([]domain.CreateBetOutcome, float32, error) { var newOdds []domain.CreateBetOutcome var totalOdds float32 = 1 - markets, err := s.prematchSvc.GetPrematchOdds(ctx, eventID) + markets, err := s.prematchSvc.GetPrematchOddsByUpcomingID(ctx, eventID) if err != nil { s.logger.Error("failed to get odds for event", "event id", eventID, "error", err) @@ -253,32 +258,20 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI if len(markets) == 0 { s.logger.Error("empty odds for event", "event id", eventID) - return nil, 0, fmt.Errorf("empty odds or event", "event id", eventID) + return nil, 0, fmt.Errorf("empty odds or event %v", eventID) } - var numMarkets = min(5, len(markets)) - var randIndex []int = make([]int, numMarkets) + var selectedMarkets []domain.Odd + numMarkets = min(numMarkets, len(markets)) for i := 0; i < numMarkets; i++ { - // Guarantee that the odd is unique - var newRandMarket int - count := 0 - for { - newRandMarket = random.Intn(len(markets)) - if !slices.Contains(randIndex, newRandMarket) { - break - } - // just in case - if count >= 5 { - s.logger.Warn("market overload", "event id", eventID) - break - } - count++ - } + randomIndex := random.Intn(len(markets)) + selectedMarkets = append(selectedMarkets, markets[randomIndex]) + markets = append(markets[:randomIndex], markets[randomIndex+1:]...) + } - randIndex[i] = newRandMarket + for _, market := range selectedMarkets { - rawOdds := markets[i].RawOdds - randomRawOdd := rawOdds[random.Intn(len(rawOdds))] + randomRawOdd := market.RawOdds[random.Intn(len(market.RawOdds))] type rawOddType struct { ID string @@ -317,13 +310,13 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI continue } - marketID, err := strconv.ParseInt(markets[i].MarketID, 10, 64) + marketID, err := strconv.ParseInt(market.MarketID, 10, 64) if err != nil { s.logger.Error("Failed to get odd id", "error", err) continue } - marketName := markets[i].MarketName + marketName := market.MarketName newOdds = append(newOdds, domain.CreateBetOutcome{ EventID: eventID, @@ -345,28 +338,48 @@ func (s *Service) GenerateRandomBetOutcomes(ctx context.Context, eventID, sportI } if len(newOdds) == 0 { - s.logger.Error("Failed to generate random outcomes") - return nil, 0, nil + s.logger.Error("Bet Outcomes is empty for market", "selectedMarket", selectedMarkets[0].MarketName) + return nil, 0, ErrGenerateRandomOutcome } return newOdds, totalOdds, nil } -func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64) (domain.CreateBetRes, error) { +func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, leagueID, sportID domain.ValidString, firstStartTime, lastStartTime domain.ValidTime) (domain.CreateBetRes, error) { // Get a unexpired event id - events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, 5, 0, domain.ValidString{}, domain.ValidString{}) + + events, _, err := s.eventSvc.GetPaginatedUpcomingEvents(ctx, + domain.ValidInt64{}, domain.ValidInt64{}, leagueID, sportID, firstStartTime, lastStartTime) if err != nil { return domain.CreateBetRes{}, err } + if len(events) == 0 { + return domain.CreateBetRes{}, ErrNoEventsAvailable + } + + // TODO: Add the option of passing number of created events + var selectedUpcomingEvents []domain.UpcomingEvent + numEventsPerBet := random.Intn(4) + 1 //Eliminate the option of 0 + + for i := 0; i < int(numEventsPerBet); i++ { + randomIndex := random.Intn(len(events)) + selectedUpcomingEvents = append(selectedUpcomingEvents, events[randomIndex]) + events = append(events[:randomIndex], events[randomIndex+1:]...) + + } + + s.logger.Info("Generating random bet events", "selectedUpcomingEvents", len(selectedUpcomingEvents)) + // Get market and odds for that var randomOdds []domain.CreateBetOutcome var totalOdds float32 = 1 - for _, event := range events { + numMarketsPerBet := random.Intn(2) + 1 + for _, event := range selectedUpcomingEvents { - newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime) + newOdds, total, err := s.GenerateRandomBetOutcomes(ctx, event.ID, event.SportID, event.HomeTeam, event.AwayTeam, event.StartTime, numMarketsPerBet) if err != nil { s.logger.Error("failed to generate random bet outcome", "event id", event.ID, "error", err) @@ -378,10 +391,12 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64) (d } if len(randomOdds) == 0 { - s.logger.Error("Failed to generate random outcomes") - return domain.CreateBetRes{}, nil + s.logger.Error("Failed to generate random any outcomes for all events") + return domain.CreateBetRes{}, ErrGenerateRandomOutcome } + s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) + var cashoutID string cashoutID, err = s.GenerateCashoutID() @@ -389,13 +404,13 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64) (d return domain.CreateBetRes{}, err } - randomNumber := strconv.FormatInt(int64(random.Intn(10)), 10) + randomNumber := strconv.FormatInt(int64(random.Intn(100000000000)), 10) newBet := domain.CreateBet{ - Amount: 123, + Amount: domain.ToCurrency(123.5), TotalOdds: totalOdds, Status: domain.OUTCOME_STATUS_PENDING, FullName: "test" + randomNumber, - PhoneNumber: randomNumber, + PhoneNumber: "0900000000", CashoutID: cashoutID, BranchID: domain.ValidInt64{Valid: true, Value: branchID}, UserID: domain.ValidInt64{Valid: true, Value: userID}, @@ -450,42 +465,97 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return s.betStore.UpdateStatus(ctx, id, status) } -func (s *Service) checkBetOutcomeForBet(ctx context.Context, eventID int64) error { - betOutcomes, err := s.betStore.GetBetOutcomeByEventID(ctx, eventID) +func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { + betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) if err != nil { - return err + return domain.OUTCOME_STATUS_PENDING, err } status := domain.OUTCOME_STATUS_PENDING for _, betOutcome := range betOutcomes { - // Check if any of them are pending + // If any of the bet outcomes are pending return if betOutcome.Status == domain.OUTCOME_STATUS_PENDING { - return nil + return domain.OUTCOME_STATUS_PENDING, ErrOutcomesNotCompleted } - if status == domain.OUTCOME_STATUS_PENDING { + if betOutcome.Status == domain.OUTCOME_STATUS_ERROR { + return domain.OUTCOME_STATUS_ERROR, nil + } + + // The bet status can only be updated if its not lost or error + // If all the bet outcomes are a win, then set the bet status to win + // If even one of the bet outcomes is a loss then set the bet status to loss + // If even one of the bet outcomes is an error, then set the bet status to error + switch status { + case domain.OUTCOME_STATUS_PENDING: status = betOutcome.Status - } else if status == domain.OUTCOME_STATUS_WIN { - status = betOutcome.Status - } else if status == domain.OUTCOME_STATUS_LOSS { - continue + case domain.OUTCOME_STATUS_WIN: + if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_HALF + } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_VOID + } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_WIN + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_LOSS: + if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_VOID: + if betOutcome.Status == domain.OUTCOME_STATUS_VOID || + betOutcome.Status == domain.OUTCOME_STATUS_WIN || + betOutcome.Status == domain.OUTCOME_STATUS_LOSS || + betOutcome.Status == domain.OUTCOME_STATUS_HALF { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + case domain.OUTCOME_STATUS_HALF: + if betOutcome.Status == domain.OUTCOME_STATUS_HALF || + betOutcome.Status == domain.OUTCOME_STATUS_WIN { + status = domain.OUTCOME_STATUS_HALF + } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { + status = domain.OUTCOME_STATUS_VOID + } else { + status = domain.OUTCOME_STATUS_ERROR + } + default: + // If the status is not pending, win, loss or error, then set the status to error + status = domain.OUTCOME_STATUS_ERROR } } - if status != domain.OUTCOME_STATUS_PENDING { - return nil + if status == domain.OUTCOME_STATUS_PENDING || status == domain.OUTCOME_STATUS_ERROR { + // If the status is pending or error, then we don't need to update the bet + s.logger.Info("bet not updated", "bet id", betID, "status", status) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("Error when processing bet outcomes") } - return s.UpdateStatus(ctx, eventID, status) + return status, nil } -func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error { +func (s *Service) UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) { betOutcome, err := s.betStore.UpdateBetOutcomeStatus(ctx, id, status) if err != nil { - return err + return domain.BetOutcome{}, err } - return s.checkBetOutcomeForBet(ctx, betOutcome.EventID) + + return betOutcome, err } diff --git a/internal/services/event/port.go b/internal/services/event/port.go index 05fd33b..94f4313 100644 --- a/internal/services/event/port.go +++ b/internal/services/event/port.go @@ -11,7 +11,7 @@ type Service interface { FetchUpcomingEvents(ctx context.Context) error GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.UpcomingEvent, error) - GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) + GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) // GetAndStoreMatchResult(ctx context.Context, eventID string) error diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 70b4f98..f344e2c 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -99,18 +99,18 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { } func (s *service) FetchUpcomingEvents(ctx context.Context) error { - sportIDs := []int{1, 18} - var totalPages int = 1 - var page int = 0 - var limit int = 100 - var count int = 0 - for _, sportID := range sportIDs { - for page != totalPages { - time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour + // sportIDs := []int{1, 18, 17} + sportIDs := []int{18} + for _, sportID := range sportIDs { + var totalPages int = 1 + var page int = 0 + var limit int = 10 + var count int = 0 + for page <= totalPages { page = page + 1 url := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s&page=%d", sportID, s.token, page) - log.Printf("📡 Fetching data for event data page %d", page) + log.Printf("📡 Fetching data for sport %d event data page %d/%d", sportID, page, min(limit, totalPages)) resp, err := http.Get(url) if err != nil { log.Printf("❌ Failed to fetch event data for page %d: %v", page, err) @@ -145,9 +145,10 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } `json:"results"` } if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + log.Printf("❌ Failed to parse json data") continue } - skippedLeague := 0 + var skippedLeague []string for _, ev := range data.Results { startUnix, _ := strconv.ParseInt(ev.Time, 10, 64) // eventID, err := strconv.ParseInt(ev.ID, 10, 64) @@ -163,7 +164,8 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } if !slices.Contains(domain.SupportedLeagues, leagueID) { - skippedLeague++ + + skippedLeague = append(skippedLeague, ev.League.Name) continue } @@ -188,11 +190,20 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { event.AwayTeamID = ev.Away.ID } - _ = s.store.SaveUpcomingEvent(ctx, event) + err = s.store.SaveUpcomingEvent(ctx, event) + if err != nil { + log.Printf("❌ Failed to save upcoming event %s", event.ID) + } } - totalPages = data.Pager.Total - if count > limit { + log.Printf("⚠️ Skipped leagues %v", len(skippedLeague)) + // log.Printf("⚠️ Total pages %v", data.Pager.Total) + totalPages = data.Pager.Total / data.Pager.PerPage + + if count >= limit { + break + } + if page > totalPages { break } count++ @@ -223,8 +234,8 @@ func (s *service) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcomi return s.store.GetExpiredUpcomingEvents(ctx) } -func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit int32, offset int32, leagueID domain.ValidString, sportID domain.ValidString) ([]domain.UpcomingEvent, int64, error) { - return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID) +func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error){ + return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID, firstStartTime, lastStartTime) } func (s *service) GetUpcomingEventByID(ctx context.Context, ID string) (domain.UpcomingEvent, error) { diff --git a/internal/services/odds/port.go b/internal/services/odds/port.go index 69fd5ee..50275b2 100644 --- a/internal/services/odds/port.go +++ b/internal/services/odds/port.go @@ -9,6 +9,8 @@ import ( type Service interface { FetchNonLiveOdds(ctx context.Context) error GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) + GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) + GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit domain.ValidInt64, offset domain.ValidInt64) ([]domain.Odd, error) GetALLPrematchOdds(ctx context.Context) ([]domain.Odd, error) GetRawOddsByMarketID(ctx context.Context, marketID string, upcomingID string) (domain.RawOddsByMarketID, error) } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index c42bab7..5d6f1d0 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -43,8 +43,7 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { var errs []error - for _, event := range eventIDs { - // time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour + for index, event := range eventIDs { eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { @@ -54,17 +53,26 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { url := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%d", s.config.Bet365Token, eventID) - log.Printf("📡 Fetching prematch odds for event ID: %d", eventID) + log.Printf("📡 Fetching prematch odds for event ID: %d (%d/%d) ", eventID, index, len(eventIDs)) - resp, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("❌ Failed to create request for event %d: %v", eventID, err) + continue + } + + resp, err := s.client.Do(req) if err != nil { log.Printf("❌ Failed to fetch prematch odds for event %d: %v", eventID, err) continue } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("❌ Failed to read response body for event %d: %v", eventID, err) + continue + } var oddsData domain.BaseNonLiveOddResponse if err := json.Unmarshal(body, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { @@ -77,17 +85,17 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { switch sportID { case domain.FOOTBALL: if err := s.parseFootball(ctx, oddsData.Results[0]); err != nil { - s.logger.Error("Failed to insert football odd") + s.logger.Error("Error while inserting football odd") errs = append(errs, err) } case domain.BASKETBALL: if err := s.parseBasketball(ctx, oddsData.Results[0]); err != nil { - s.logger.Error("Failed to insert basketball odd") + s.logger.Error("Error while inserting basketball odd") errs = append(errs, err) } case domain.ICE_HOCKEY: if err := s.parseIceHockey(ctx, oddsData.Results[0]); err != nil { - s.logger.Error("Failed to insert ice hockey odd") + s.logger.Error("Error while inserting ice hockey odd") errs = append(errs, err) } @@ -107,8 +115,8 @@ func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) er return err } if footballRes.EventID == "" && footballRes.FI == "" { - s.logger.Error("Skipping result with no valid Event ID") - return fmt.Errorf("Skipping result with no valid Event ID") + s.logger.Error("Skipping football result with no valid Event ID", "eventID", footballRes.EventID, "fi", footballRes.FI) + return fmt.Errorf("Skipping football result with no valid Event ID Event ID %v", footballRes.EventID) } sections := map[string]domain.OddsSection{ "main": footballRes.Main, @@ -121,7 +129,8 @@ func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) er for oddCategory, section := range sections { if err := s.storeSection(ctx, footballRes.EventID, footballRes.FI, oddCategory, section); err != nil { - s.logger.Error("Skipping result with no valid Event ID") + s.logger.Error("Error storing football section", "eventID", footballRes.FI, "odd", oddCategory) + log.Printf("⚠️ Error when storing football %v", err) errs = append(errs, err) } } @@ -136,12 +145,12 @@ func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) er func (s *ServiceImpl) parseBasketball(ctx context.Context, res json.RawMessage) error { var basketballRes domain.BasketballOddsResponse if err := json.Unmarshal(res, &basketballRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "error", err) + s.logger.Error("Failed to unmarshal basketball result", "error", err) return err } if basketballRes.EventID == "" && basketballRes.FI == "" { - s.logger.Error("Skipping result with no valid Event ID") - return fmt.Errorf("Skipping result with no valid Event ID") + s.logger.Error("Skipping basketball result with no valid Event ID") + return fmt.Errorf("Skipping basketball result with no valid Event ID") } sections := map[string]domain.OddsSection{ "main": basketballRes.Main, @@ -177,7 +186,7 @@ func (s *ServiceImpl) parseBasketball(ctx context.Context, res json.RawMessage) func (s *ServiceImpl) parseIceHockey(ctx context.Context, res json.RawMessage) error { var iceHockeyRes domain.IceHockeyOddsResponse if err := json.Unmarshal(res, &iceHockeyRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "error", err) + s.logger.Error("Failed to unmarshal ice hockey result", "error", err) return err } if iceHockeyRes.EventID == "" && iceHockeyRes.FI == "" { @@ -229,17 +238,30 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName continue } - marketID, err := market.ID.Int64() + // Check if the market id is a string + var marketIDstr string + err := json.Unmarshal(market.ID, &marketIDstr) if err != nil { - s.logger.Error("Invalid market id", "marketID", marketID) + // check if its int + var marketIDint int + err := json.Unmarshal(market.ID, &marketIDint) + if err != nil { + s.logger.Error("Invalid market id") + errs = append(errs, err) + } + } + + marketIDint, err := strconv.ParseInt(marketIDstr, 10, 64) + if err != nil { + s.logger.Error("Invalid market id", "marketID", marketIDstr, "marketName", market.Name) errs = append(errs, err) continue } - isSupported, ok := domain.SupportedMarkets[marketID] + isSupported, ok := domain.SupportedMarkets[marketIDint] if !ok || !isSupported { - s.logger.Info("Unsupported market_id", "marketID", marketID) + // s.logger.Info("Unsupported market_id", "marketID", marketIDint, "marketName", market.Name) continue } @@ -249,7 +271,7 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName MarketCategory: sectionName, MarketType: marketType, MarketName: market.Name, - MarketID: market.ID.String(), + MarketID: marketIDstr, UpdatedAt: updatedAt, Odds: market.Odds, } @@ -285,6 +307,10 @@ func (s *ServiceImpl) GetRawOddsByMarketID(ctx context.Context, marketID string, return rows, nil } -func (s *ServiceImpl) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset int32) ([]domain.Odd, error) { - return s.store.GetPrematchOddsByUpcomingID(ctx, upcomingID, limit, offset) +func (s *ServiceImpl) GetPrematchOddsByUpcomingID(ctx context.Context, upcomingID string) ([]domain.Odd, error) { + return s.store.GetPrematchOddsByUpcomingID(ctx, upcomingID) +} + +func (s *ServiceImpl) GetPaginatedPrematchOddsByUpcomingID(ctx context.Context, upcomingID string, limit, offset domain.ValidInt64) ([]domain.Odd, error) { + return s.store.GetPaginatedPrematchOddsByUpcomingID(ctx, upcomingID, limit, offset) } diff --git a/internal/services/result/eval.go b/internal/services/result/eval.go index 8096e3a..93f3d64 100644 --- a/internal/services/result/eval.go +++ b/internal/services/result/eval.go @@ -9,6 +9,8 @@ import ( ) // Football evaluations + +// Full Time Result betting is a type of bet where the bettor predicts the outcome of a match at the end of the full 90 minutes of play. func evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "1": // Home win @@ -27,15 +29,16 @@ func evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Over/Under betting is a type of bet where the bettor predicts whether the total number of goals scored in a match will be over or under a specified number. func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := float64(score.Home + score.Away) threshold, err := strconv.ParseFloat(outcome.OddName, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } if outcome.OddHeader == "Over" { @@ -53,9 +56,10 @@ func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } +// Correct Score betting is a type of bet where the bettor predicts the exact final score of a match. func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { expectedScore := fmt.Sprintf("%d-%d", score.Home, score.Away) if outcome.OddName == expectedScore { @@ -64,6 +68,8 @@ func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away in return domain.OUTCOME_STATUS_LOSS, nil } +// Half Time Result betting is a type of bet where the bettor predicts the outcome of a match at the end of the first half. +// This is the same as the full time result but only for the first half of the game func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { return evaluateFullTimeResult(outcome, score) } @@ -71,43 +77,90 @@ func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away // This is a multiple outcome checker for the asian handicap and other kinds of bets // The only outcome that are allowed are "Both Bets win", "Both Bets Lose", "Half Win and Half Void" func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.OutcomeStatus) (domain.OutcomeStatus, error) { + if secondOutcome == domain.OUTCOME_STATUS_PENDING { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("cannot check pending outcome") + } + + if outcome == domain.OUTCOME_STATUS_ERROR || secondOutcome == domain.OUTCOME_STATUS_ERROR { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("❌ mutli outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + } + switch outcome { case domain.OUTCOME_STATUS_PENDING: return secondOutcome, nil case domain.OUTCOME_STATUS_WIN: if secondOutcome == domain.OUTCOME_STATUS_WIN { return domain.OUTCOME_STATUS_WIN, nil + } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { + return domain.OUTCOME_STATUS_LOSS, nil + } else if secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_HALF, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { return domain.OUTCOME_STATUS_HALF, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } case domain.OUTCOME_STATUS_LOSS: - if secondOutcome == domain.OUTCOME_STATUS_LOSS { + if secondOutcome == domain.OUTCOME_STATUS_LOSS || + secondOutcome == domain.OUTCOME_STATUS_WIN || + secondOutcome == domain.OUTCOME_STATUS_HALF { return domain.OUTCOME_STATUS_LOSS, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { return domain.OUTCOME_STATUS_HALF, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } case domain.OUTCOME_STATUS_VOID: if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_LOSS { return domain.OUTCOME_STATUS_HALF, nil + } else if secondOutcome == domain.OUTCOME_STATUS_VOID || secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_VOID, nil } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") + } + case domain.OUTCOME_STATUS_HALF: + if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_HALF { + return domain.OUTCOME_STATUS_HALF, nil + } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { + return domain.OUTCOME_STATUS_LOSS, nil + } else if secondOutcome == domain.OUTCOME_STATUS_VOID { + return domain.OUTCOME_STATUS_VOID, nil + } else { + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid multi outcome") + fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } } +// Asian Handicap betting is a type of betting that eliminates the possibility of a draw by giving one team a virtual advantage or disadvantage. +// +// { +// "id": "548319135", +// "odds": "1.750", +// "header": "1", +// "handicap": "+0.5, +1.0" +// }, +// +// { +// "id": "548319139", +// "odds": "1.950", +// "header": "2", +// "handicap": "-0.5, -1.0" +// } func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { handicapList := strings.Split(outcome.OddHandicap, ",") newOutcome := domain.OUTCOME_STATUS_PENDING for _, handicapStr := range handicapList { + handicapStr = strings.TrimSpace(handicapStr) handicap, err := strconv.ParseFloat(handicapStr, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) } adjustedHomeScore := float64(score.Home) adjustedAwayScore := float64(score.Away) @@ -116,49 +169,117 @@ func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away i } else if outcome.OddHeader == "2" { // Away team adjustedAwayScore += handicap } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } if adjustedHomeScore > adjustedAwayScore { if outcome.OddHeader == "1" { newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } } else if adjustedHomeScore < adjustedAwayScore { if outcome.OddHeader == "2" { newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) if err != nil { - fmt.Printf("multi outcome check error") - return domain.OUTCOME_STATUS_PENDING, err + return domain.OUTCOME_STATUS_ERROR, err } } return newOutcome, nil } +// Goal Line betting, also known as Over/Under betting, +// involves predicting the total number of goals scored in a match, regardless of which team wins. +// +// { +// "id": "548319141", +// "odds": "1.800", +// "header": "Over", +// "name": "1.5, 2.0" +// }, +// +// { +// "id": "548319146", +// "odds": "1.900", +// "header": "Under", +// "name": "1.5, 2.0" +// } func evaluateGoalLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { - return evaluateGoalsOverUnder(outcome, score) + + totalGoals := float64(score.Home + score.Away) + thresholdList := strings.Split(outcome.OddName, ",") + + newOutcome := domain.OUTCOME_STATUS_PENDING + for _, thresholdStr := range thresholdList { + thresholdStr = strings.TrimSpace(thresholdStr) + threshold, err := strconv.ParseFloat(thresholdStr, 64) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: '%s', %v", thresholdStr, err) + } + + oddHeader := strings.TrimSpace(outcome.OddHeader) + if oddHeader == "Over" { + if totalGoals > threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + } else if totalGoals == threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + } else if oddHeader == "Under" { + if totalGoals < threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } else if totalGoals == threshold { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + + return domain.OUTCOME_STATUS_ERROR, err + } + } + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: '%s'", oddHeader) + } + + } + + return newOutcome, nil } +// First Team To Score betting is a type of bet where the bettor predicts which team will score first in a match. +// We can get this from the "events" field on the result json func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]string) (domain.OutcomeStatus, error) { for _, event := range events { if strings.Contains(event["text"], "1st Goal") || strings.Contains(event["text"], "Goal 1") { @@ -173,6 +294,7 @@ func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]str return domain.OUTCOME_STATUS_VOID, nil // No goals scored } +// Goals Odd/Even betting is a type of bet where the bettor predicts whether the total number of goals scored in a match will be odd or even. func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalGoals := score.Home + score.Away isOdd := totalGoals%2 == 1 @@ -184,6 +306,7 @@ func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away in return domain.OUTCOME_STATUS_LOSS, nil } +// Double Chance betting is a type of bet where the bettor predicts two of the three possible outcomes of a match. func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { isHomeWin := score.Home > score.Away isDraw := score.Home == score.Away @@ -206,10 +329,11 @@ func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away in } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Draw No Bet betting is a type of bet where the bettor predicts the outcome of a match, but if the match ends in a draw, the bet is voided. func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { if score.Home == score.Away { return domain.OUTCOME_STATUS_VOID, nil @@ -222,8 +346,9 @@ func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_LOSS, nil } -// basketball evaluations +// Basketball evaluations +// Game Lines is an aggregate of money line, spread and total betting markets in one func evaluateGameLines(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "Money Line": @@ -235,10 +360,11 @@ func evaluateGameLines(outcome domain.BetOutcome, score struct{ Home, Away int } case "Total": return evaluateTotalOverUnder(outcome, score) default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Money Line betting is a type of bet where the bettor predicts the outcome of a match without any point spread. func evaluateMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddHeader { case "1": @@ -258,21 +384,22 @@ func evaluateMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int } } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Total Over/Under betting is a type of bet where the bettor predicts whether the total number of points scored in a match will be over or under a specified number. func evaluateTotalOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -294,26 +421,28 @@ func evaluateTotalOverUnder(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } +// Result and Total betting is a type of bet where the bettor predicts +// the outcome of a match and whether the total number of points scored will be over or under a specified number. func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } overUnder := overUnderStr[0] if overUnder != "Over" && overUnder != "Under" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -321,6 +450,10 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away switch outcome.OddHeader { case "1": + if score.Home < score.Away { + return domain.OUTCOME_STATUS_LOSS, nil + } + if overUnder == "Over" && totalScore > threshold { return domain.OUTCOME_STATUS_WIN, nil } else if overUnder == "Under" && totalScore < threshold { @@ -328,6 +461,9 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away } return domain.OUTCOME_STATUS_LOSS, nil case "2": + if score.Away < score.Home { + return domain.OUTCOME_STATUS_LOSS, nil + } if overUnder == "Over" && totalScore > threshold { return domain.OUTCOME_STATUS_WIN, nil } else if overUnder == "Under" && totalScore < threshold { @@ -336,27 +472,29 @@ func evaluateResultAndTotal(outcome domain.BetOutcome, score struct{ Home, Away return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) } } +// Team Total betting is a type of bet where the bettor predicts the total number of points scored by a specific team in a match +// is over or under a specified number. func evaluateTeamTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The handicap will be in the format "U {float}" or "O {float}" // U and O denoting over and under for this case overUnderStr := strings.Split(outcome.OddHandicap, " ") if len(overUnderStr) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) } overUnder := overUnderStr[0] if overUnder != "Over" && overUnder != "Under" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over under: %s", outcome.OddHeader) } threshold, err := strconv.ParseFloat(overUnderStr[1], 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHandicap) } // Since the threshold will come in a xx.5 format, there is no VOID for this kind of bet @@ -380,11 +518,12 @@ func evaluateTeamTotal(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed to parse over and under: %s", outcome.OddName) } } -// Evaluate Result and Both Teams To Score X Points +// Result and Both Teams To Score X Points is a type of bet where the bettor predicts whether both teams will score a certain number of points +// and also the result fo the match func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { // The name parameter will hold value "name": "{team_name} and {Yes | No}" @@ -400,14 +539,14 @@ func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away } else if scoreCheckSplit == "No" { isScorePoints = false } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } teamName := strings.TrimSpace(strings.Join(oddNameSplit[:len(oddNameSplit)-2], "")) threshold, err := strconv.ParseInt(outcome.OddHeader, 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddHeader) } switch teamName { @@ -428,18 +567,18 @@ func evaluateResultAndBTTSX(outcome domain.BetOutcome, score struct{ Home, Away } } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("team name error: %s", teamName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("team name error: %s", teamName) } return domain.OUTCOME_STATUS_LOSS, nil } -// Both Teams To Score X Points +// Both Teams To Score X Points is a type of bet where the bettor predicts whether both teams will score a certain number of points. func evaluateBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { threshold, err := strconv.ParseInt(outcome.OddName, 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) } switch outcome.OddHeader { @@ -453,12 +592,13 @@ func evaluateBTTSX(outcome domain.BetOutcome, score struct{ Home, Away int }) (d } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) } return domain.OUTCOME_STATUS_LOSS, nil } +// Money Line 3 Way betting is a type of bet where the bettor predicts the outcome of a match with three possible outcomes: home win, away win, or draw. func evaluateMoneyLine3Way(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { switch outcome.OddName { case "1": // Home win @@ -477,23 +617,24 @@ func evaluateMoneyLine3Way(outcome domain.BetOutcome, score struct{ Home, Away i } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } } +// Double Result betting is a type of bet where the bettor predicts the outcome of a match at both half-time and full-time. func evaluateDoubleResult(outcome domain.BetOutcome, firstHalfScore struct{ Home, Away int }, secondHalfScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { halfWins := strings.Split(outcome.OddName, "-") if len(halfWins) != 2 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd name: %s", outcome.OddName) } firstHalfWinner := strings.TrimSpace(halfWins[0]) secondHalfWinner := strings.TrimSpace(halfWins[1]) if firstHalfWinner != outcome.HomeTeamName && firstHalfWinner != outcome.AwayTeamName && firstHalfWinner != "Tie" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", firstHalfWinner) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", firstHalfWinner) } if secondHalfWinner != outcome.HomeTeamName && secondHalfWinner != outcome.AwayTeamName && secondHalfWinner != "Tie" { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", firstHalfWinner) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", firstHalfWinner) } switch { @@ -517,6 +658,7 @@ func evaluateDoubleResult(outcome domain.BetOutcome, firstHalfScore struct{ Home return domain.OUTCOME_STATUS_WIN, nil } +// Highest Scoring Half betting is a type of bet where the bettor predicts which half of the match will have the highest total score. func evaluateHighestScoringHalf(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstHalfTotal := firstScore.Home + firstScore.Away secondHalfTotal := secondScore.Home + secondScore.Away @@ -534,11 +676,12 @@ func evaluateHighestScoringHalf(outcome domain.BetOutcome, firstScore struct{ Ho return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Highest Scoring Quarter betting is a type of bet where the bettor predicts which quarter of the match will have the highest score. func evaluateHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }, fourthScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstQuarterTotal := firstScore.Home + firstScore.Away secondQuarterTotal := secondScore.Home + secondScore.Away @@ -567,18 +710,20 @@ func evaluateHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Handicap and Total betting is a combination of spread betting and total points betting +// where the bettor predicts the outcome of a match with a point spread and the total number of points scored is over or under a specified number. func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { nameSplit := strings.Split(outcome.OddName, " ") // Evaluate from bottom to get the threshold and find out if its over or under threshold, err := strconv.ParseFloat(nameSplit[len(nameSplit)-1], 10) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing threshold: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing threshold: %s", outcome.OddName) } total := float64(score.Home + score.Away) overUnder := nameSplit[len(nameSplit)-2] @@ -591,12 +736,12 @@ func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Awa return domain.OUTCOME_STATUS_LOSS, nil } } else { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing over and under: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing over and under: %s", outcome.OddName) } handicap, err := strconv.ParseFloat(nameSplit[len(nameSplit)-4], 10) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing handicap: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing handicap: %s", outcome.OddName) } teamName := strings.TrimSpace(strings.Join(nameSplit[:len(nameSplit)-4], "")) @@ -618,21 +763,22 @@ func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Awa } return domain.OUTCOME_STATUS_LOSS, nil default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("failed parsing team name: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("failed parsing team name: %s", outcome.OddName) } } +// Winning Margin betting is a type of bet where the bettor predicts the margin of victory in a match. func evaluateWinningMargin(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { marginSplit := strings.Split(outcome.OddName, "") if len(marginSplit) < 1 { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } margin, err := strconv.ParseInt(marginSplit[0], 10, 64) if err != nil { - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } isGtr := false @@ -656,9 +802,10 @@ func evaluateWinningMargin(outcome domain.BetOutcome, score struct{ Home, Away i return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddheader: %s", outcome.OddHeader) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddheader: %s", outcome.OddHeader) } +// Highest Scoring Period betting is a type of bet where the bettor predicts which period of the match will have the highest total score. func evaluateHighestScoringPeriod(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { firstPeriodTotal := firstScore.Home + firstScore.Away secondPeriodTotal := secondScore.Home + secondScore.Away @@ -682,11 +829,12 @@ func evaluateHighestScoringPeriod(outcome domain.BetOutcome, firstScore struct{ return domain.OUTCOME_STATUS_WIN, nil } default: - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } return domain.OUTCOME_STATUS_LOSS, nil } +// Tied After Regulation is a type of bet where the bettor predicts whether the match will end in a tie after regulation time. func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Home, Away int }) (domain.OutcomeStatus, error) { totalScore := struct{ Home, Away int }{0, 0} for _, score := range scores { @@ -706,6 +854,5 @@ func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Hom return domain.OUTCOME_STATUS_LOSS, nil } - return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) } - diff --git a/internal/services/result/football_test.go b/internal/services/result/football_test.go new file mode 100644 index 0000000..0130cf6 --- /dev/null +++ b/internal/services/result/football_test.go @@ -0,0 +1,30 @@ +package result + +import ( + "testing" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func TestEvaluateFullTimeResult(t *testing.T) { + tests := []struct { + name string + outcome domain.BetOutcome + score struct{ Home, Away int } + expected domain.OutcomeStatus + }{ + {"Home win", domain.BetOutcome{OddName: "1"}, struct{ Home, Away int }{2, 1}, domain.OUTCOME_STATUS_WIN}, + {"Away win", domain.BetOutcome{OddName: "2"}, struct{ Home, Away int }{1, 2}, domain.OUTCOME_STATUS_WIN}, + {"Draw", domain.BetOutcome{OddName: "Draw"}, struct{ Home, Away int }{1, 1}, domain.OUTCOME_STATUS_WIN}, + {"Home selected, but Draw", domain.BetOutcome{OddName: "1"}, struct{ Home, Away int }{1, 1}, domain.OUTCOME_STATUS_LOSS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, _ := evaluateFullTimeResult(tt.outcome, tt.score) + if status != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, status) + } + }) + } +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index c2ef4b1..d7cc80a 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -45,9 +45,9 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { s.logger.Error("Failed to fetch events") return err } - fmt.Printf("Expired Events: %d \n", len(events)) - - for _, event := range events { + fmt.Printf("⚠️ Expired Events: %d \n", len(events)) + for i, event := range events { + fmt.Printf("🕛 Checking if event has bets placed on it %v (%d/%d) \n", event.ID, i+1, len(events)) eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { s.logger.Error("Failed to parse event id") @@ -59,46 +59,89 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { return err } - for _, outcome := range outcomes { + if len(outcomes) == 0 { + continue + } + + isDeleted := true + for j, outcome := range outcomes { + fmt.Printf("⚙️ Processing 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + if outcome.Expires.After(time.Now()) { + isDeleted = false + s.logger.Info("Outcome is not expired yet", "event_id", event.ID, "outcome_id", outcome.ID) continue } sportID, err := strconv.ParseInt(event.SportID, 10, 64) if err != nil { s.logger.Error("Sport ID is invalid", "event_id", outcome.EventID, "error", err) + isDeleted = false continue } + // TODO: optimize this because the result is being fetched for each outcome which will have the same event id but different market id result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { - s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err) + fmt.Printf("❌ failed to parse 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "outcome_id", outcome.ID, "market_id", outcome.MarketID, "market", outcome.MarketName, "error", err) + isDeleted = false continue } - // _, err = s.repo.CreateResult(ctx, domain.CreateResult{ - // BetOutcomeID: outcome.ID, - // EventID: outcome.EventID, - // OddID: outcome.OddID, - // MarketID: outcome.MarketID, - // Status: result.Status, - // Score: result.Score, - // }) - // if err != nil { - // s.logger.Error("Failed to store result", "bet_outcome_id", outcome.ID, "error", err) - // continue - // } - - _, err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) + outcome, err = s.betSvc.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status) if err != nil { + isDeleted = false s.logger.Error("Failed to update bet outcome status", "bet_outcome_id", outcome.ID, "error", err) continue } + if outcome.Status == domain.OUTCOME_STATUS_ERROR || outcome.Status == domain.OUTCOME_STATUS_PENDING { + fmt.Printf("❌ Error while updating 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + + s.logger.Error("Outcome is pending or error", "event_id", outcome.EventID, "outcome_id", outcome.ID) + isDeleted = false + continue + } + + fmt.Printf("✅ Successfully updated 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", + outcome.MarketName, + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + + status, err := s.betSvc.CheckBetOutcomeForBet(ctx, outcome.BetID) + if err != nil { + if err != bet.ErrOutcomesNotCompleted { + s.logger.Error("Failed to check bet outcome for bet", "event_id", outcome.EventID, "error", err) + } + continue + } + fmt.Printf("🧾 Updating bet status for event %v (%d/%d) to %v\n", event.ID, j+1, len(outcomes), status.String()) + err = s.betSvc.UpdateStatus(ctx, outcome.BetID, status) + if err != nil { + s.logger.Error("Failed to update bet status", "event id", outcome.EventID, "error", err) + continue + } + fmt.Printf("✅ Successfully updated 🎫 Bet for event %v(%v) (%d/%d) \n", + event.HomeTeam+" "+event.AwayTeam, event.ID, + j+1, len(outcomes)) + } - err = s.repo.DeleteEvent(ctx, event.ID) - if err != nil { - s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) - return err + if isDeleted { + // err = s.repo.DeleteEvent(ctx, event.ID) + // if err != nil { + // s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) + // return err + // } } + } return nil @@ -248,7 +291,7 @@ func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marke corners := parseStats(result.Stats.Corners) status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, corners, result.Events) if err != nil { - s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + s.logger.Error("Failed to evaluate football outcome", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err } diff --git a/internal/services/result/service_test.go b/internal/services/result/service_test.go deleted file mode 100644 index 2705049..0000000 --- a/internal/services/result/service_test.go +++ /dev/null @@ -1 +0,0 @@ -package result diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go index 1d86313..509f353 100644 --- a/internal/services/ticket/service.go +++ b/internal/services/ticket/service.go @@ -37,3 +37,7 @@ func (s *Service) UpdateTicketOutcomeStatus(ctx context.Context, id int64, statu func (s *Service) DeleteTicket(ctx context.Context, id int64) error { return s.ticketStore.DeleteTicket(ctx, id) } + +func (s *Service) DeleteOldTickets(ctx context.Context) error { + return s.ticketStore.DeleteOldTickets(ctx) +} diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 4dccd22..e9bca42 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -1,8 +1,8 @@ package httpserver import ( - // "context" "context" + "log" // "time" @@ -10,6 +10,7 @@ import ( eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" resultsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/result" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/robfig/cron/v3" ) @@ -20,14 +21,14 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - { - spec: "0 0 * * * *", // Every 1 hour - task: func() { - if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - log.Printf("FetchUpcomingEvents error: %v", err) - } - }, - }, + // { + // spec: "0 0 * * * *", // Every 1 hour + // task: func() { + // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + // log.Printf("FetchUpcomingEvents error: %v", err) + // } + // }, + // }, // { // spec: "*/5 * * * * *", // Every 5 seconds @@ -37,14 +38,14 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S // } // }, // }, - { - spec: "0 */15 * * * *", // Every 15 minutes - task: func() { - if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - log.Printf("FetchNonLiveOdds error: %v", err) - } - }, - }, + // { + // spec: "0 */15 * * * *", // Every 15 minutes + // task: func() { + // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + // log.Printf("FetchNonLiveOdds error: %v", err) + // } + // }, + // }, // { // spec: "0 */15 * * * *", // task: func() { @@ -80,6 +81,7 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } for _, job := range schedule { + job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } @@ -88,3 +90,34 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S c.Start() log.Println("Cron jobs started for event and odds services") } + +func StartTicketCrons(ticketService ticket.Service) { + c := cron.New(cron.WithSeconds()) + + schedule := []struct { + spec string + task func() + }{ + { + spec: "0 0 * * * *", // Every hour + task: func() { + log.Println("Deleting old tickets...") + if err := ticketService.DeleteOldTickets(context.Background()); err != nil { + log.Printf("Failed to remove old ticket: %v", err) + } else { + log.Printf("Successfully deleted old tickets") + } + }, + }, + } + + for _, job := range schedule { + job.task() + if _, err := c.AddFunc(job.spec, job.task); err != nil { + log.Fatalf("Failed to schedule cron job: %v", err) + } + } + + c.Start() + log.Println("Cron jobs started for ticket service") +} diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index d6952a1..860d128 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -2,8 +2,10 @@ package handlers import ( "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -15,7 +17,7 @@ import ( // @Accept json // @Produce json // @Param createBet body domain.CreateBetReq true "Creates bet" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet [post] @@ -54,7 +56,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param createBet body domain.RandomBetReq true "Create Random bet" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /random/bet [post] @@ -64,6 +66,45 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { userID := c.Locals("user_id").(int64) // role := c.Locals("role").(domain.Role) + leagueIDQuery := c.Query("league_id") + sportIDQuery := c.Query("sport_id") + firstStartTimeQuery := c.Query("first_start_time") + lastStartTimeQuery := c.Query("last_start_time") + + leagueID := domain.ValidString{ + Value: leagueIDQuery, + Valid: leagueIDQuery != "", + } + sportID := domain.ValidString{ + Value: sportIDQuery, + Valid: sportIDQuery != "", + } + + var firstStartTime domain.ValidTime + if firstStartTimeQuery != "" { + firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) + if err != nil { + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + firstStartTime = domain.ValidTime{ + Value: firstStartTimeParsed, + Valid: true, + } + } + var lastStartTime domain.ValidTime + if lastStartTimeQuery != "" { + lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery) + if err != nil { + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + lastStartTime = domain.ValidTime{ + Value: lastStartTimeParsed, + Valid: true, + } + } + var req domain.RandomBetReq if err := c.BodyParser(&req); err != nil { h.logger.Error("Failed to parse RandomBet request", "error", err) @@ -75,10 +116,14 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - res, err := h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID) + res, err := h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) if err != nil { h.logger.Error("Random Bet failed", "error", err) + switch err { + case bet.ErrNoEventsAvailable: + return fiber.NewError(fiber.StatusBadRequest, "No events found") + } return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } @@ -92,7 +137,7 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { // @Tags bet // @Accept json // @Produce json -// @Success 200 {array} BetRes +// @Success 200 {array} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet [get] @@ -118,7 +163,7 @@ func (h *Handler) GetAllBet(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "Bet ID" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet/{id} [get] @@ -149,7 +194,7 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path string true "cashout ID" -// @Success 200 {object} BetRes +// @Success 200 {object} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /bet/cashout/{id} [get] diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index 905da0b..4ec72e7 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -498,7 +498,7 @@ func (h *Handler) GetBranchOperations(c *fiber.Ctx) error { // @Tags branch // @Accept json // @Produce json -// @Success 200 {array} BetRes +// @Success 200 {array} domain.BetRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /branch/{id}/bets [get] diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go index e4790e8..52e3780 100644 --- a/internal/web_server/handlers/prematch.go +++ b/internal/web_server/handlers/prematch.go @@ -2,6 +2,7 @@ package handlers import ( "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" @@ -106,6 +107,8 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { pageSize := c.QueryInt("page_size", 10) leagueIDQuery := c.Query("league_id") sportIDQuery := c.Query("sport_id") + firstStartTimeQuery := c.Query("first_start_time") + lastStartTimeQuery := c.Query("last_start_time") leagueID := domain.ValidString{ Value: leagueIDQuery, @@ -116,7 +119,41 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { Valid: sportIDQuery != "", } - events, total, err := h.eventSvc.GetPaginatedUpcomingEvents(c.Context(), int32(pageSize), int32(page)-1, leagueID, sportID) + var firstStartTime domain.ValidTime + if firstStartTimeQuery != "" { + firstStartTimeParsed, err := time.Parse(time.RFC3339, firstStartTimeQuery) + if err != nil { + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + firstStartTime = domain.ValidTime{ + Value: firstStartTimeParsed, + Valid: true, + } + } + var lastStartTime domain.ValidTime + if lastStartTimeQuery != "" { + lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery) + if err != nil { + h.logger.Error("invalid start_time format", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid start_time format", nil, nil) + } + lastStartTime = domain.ValidTime{ + Value: lastStartTimeParsed, + Valid: true, + } + } + + limit := domain.ValidInt64{ + Value: int64(pageSize), + Valid: true, + } + offset := domain.ValidInt64{ + Value: int64(page - 1), + Valid: true, + } + events, total, err := h.eventSvc.GetPaginatedUpcomingEvents( + c.Context(), limit, offset, leagueID, sportID, firstStartTime, lastStartTime) // fmt.Printf("League ID: %v", leagueID) if err != nil { @@ -183,7 +220,7 @@ func (h *Handler) GetPrematchOddsByUpcomingID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid offset value", nil, nil) } - odds, err := h.prematchSvc.GetPrematchOddsByUpcomingID(c.Context(), upcomingID, int32(limit), int32(offset)) + odds, err := h.prematchSvc.GetPrematchOddsByUpcomingID(c.Context(), upcomingID) if err != nil { return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve prematch odds", nil, nil) } diff --git a/makefile b/makefile index 79017cf..15d4368 100644 --- a/makefile +++ b/makefile @@ -1,7 +1,7 @@ include .env .PHONY: test test: - @go test ./app + @go test ./... .PHONY: coverage coverage: @mkdir -p coverage From 4c6fb733429ef9df0c092cfef1bfa2de543873ab Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Fri, 16 May 2025 22:38:10 +0300 Subject: [PATCH 08/15] integration issues --- db/migrations/000001_fortune.up.sql | 74 +++++- db/query/company.sql | 6 +- docs/docs.go | 226 +++++++++++++++++- docs/swagger.json | 226 +++++++++++++++++- docs/swagger.yaml | 149 +++++++++++- gen/db/company.sql.go | 37 +-- gen/db/models.go | 17 +- internal/domain/bet.go | 3 +- internal/domain/company.go | 15 +- internal/domain/league.go | 5 +- internal/domain/result.go | 18 ++ internal/domain/resultres.go | 7 +- internal/domain/sportmarket.go | 54 +++-- internal/repository/company.go | 23 +- internal/repository/event.go | 4 +- internal/repository/user.go | 16 ++ internal/services/bet/service.go | 16 +- internal/services/event/service.go | 10 +- internal/services/odds/service.go | 2 +- internal/services/result/eval.go | 122 +++++++++- internal/services/result/service.go | 117 +++++++-- internal/services/user/port.go | 1 + internal/services/user/user.go | 4 + internal/web_server/cron.go | 64 ++--- internal/web_server/handlers/bet_handler.go | 22 +- .../web_server/handlers/branch_handler.go | 59 +++++ .../web_server/handlers/company_handler.go | 30 ++- .../web_server/handlers/ticket_handler.go | 2 +- internal/web_server/handlers/user.go | 74 +++++- internal/web_server/routes.go | 12 +- 30 files changed, 1213 insertions(+), 202 deletions(-) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index c351619..a2568d1 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -234,12 +234,17 @@ CREATE TABLE companies ( wallet_id BIGINT NOT NULL ); -- Views -CREATE VIEW companies_with_wallets AS +CREATE VIEW companies_details AS SELECT companies.*, wallets.balance, - wallets.is_active + wallets.is_active, + users.first_name AS admin_first_name, + users.last_name AS admin_last_name, + users.phone_number AS admin_phone_number FROM companies - JOIN wallets ON wallets.id = companies.wallet_id; + JOIN wallets ON wallets.id = companies.wallet_id + JOIN users ON users.id = companies.admin_id; +; CREATE VIEW branch_details AS SELECT branches.*, CONCAT(users.first_name, ' ', users.last_name) AS manager_name, @@ -290,11 +295,11 @@ ALTER TABLE branch_operations ADD CONSTRAINT fk_branch_operations_operations FOREIGN KEY (operation_id) REFERENCES supported_operations(id) ON DELETE CASCADE, ADD CONSTRAINT fk_branch_operations_branches FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE; ALTER TABLE branch_cashiers -ADD CONSTRAINT fk_branch_cashiers_users FOREIGN KEY (user_id) REFERENCES users(id), - ADD CONSTRAINT fk_branch_cashiers_branches FOREIGN KEY (branch_id) REFERENCES branches(id); +ADD CONSTRAINT fk_branch_cashiers_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_branch_cashiers_branches FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE; ALTER TABLE companies ADD CONSTRAINT fk_companies_admin FOREIGN KEY (admin_id) REFERENCES users(id), - ADD CONSTRAINT fk_companies_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id); + ADD CONSTRAINT fk_companies_wallet FOREIGN KEY (wallet_id) REFERENCES wallets(id) ON DELETE CASCADE; ----------------------------------------------seed data------------------------------------------------------------- -------------------------------------- DO NOT USE IN PRODUCTION------------------------------------------------- CREATE EXTENSION IF NOT EXISTS pgcrypto; @@ -344,7 +349,7 @@ VALUES ( 'Test', 'Admin', 'test.admin@gmail.com', - '0911111111', + '0988554466', crypt('password123', gen_salt('bf'))::bytea, 'admin', TRUE, @@ -400,7 +405,7 @@ VALUES ( 'Kirubel', 'Kibru', 'kirubeljkl679 @gmail.com', - '0911111111', + '0911554486', crypt('password@123', gen_salt('bf'))::bytea, 'super_admin', TRUE, @@ -412,8 +417,7 @@ VALUES ( ); INSERT INTO supported_operations (name, description) VALUES ('SportBook', 'Sportbook operations'), - ('Virtual', 'Virtual operations'), - ('GameZone', 'GameZone operations'); + ('Virtual', 'Virtual operations'); INSERT INTO wallets ( balance, is_withdraw, @@ -433,4 +437,54 @@ VALUES ( TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ); +INSERT INTO companies ( + name, + admin_id, + wallet_id + ) +values ( + 'Test Company', + 2, + 1 + ); +INSERT INTO wallets ( + balance, + is_withdraw, + is_bettable, + is_transferable, + user_id, + is_active, + created_at, + updated_at + ) +VALUES ( + 10000, + TRUE, + TRUE, + TRUE, + 2, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ); +INSERT INTO branches ( + name, + location, + wallet_id, + branch_manager_id, + company_id, + is_self_owned, + created_at, + updated_at + ) +values ( + 'Test Branch', + 'Addis Ababa', + 2, + 2, + 1, + TRUE, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/db/query/company.sql b/db/query/company.sql index 35d37c1..3315132 100644 --- a/db/query/company.sql +++ b/db/query/company.sql @@ -8,14 +8,14 @@ VALUES ($1, $2, $3) RETURNING *; -- name: GetAllCompanies :many SELECT * -FROM companies_with_wallets; +FROM companies_details; -- name: GetCompanyByID :one SELECT * -FROM companies_with_wallets +FROM companies_details WHERE id = $1; -- name: SearchCompanyByName :many SELECT * -FROM companies_with_wallets +FROM companies_details WHERE name ILIKE '%' || $1 || '%'; -- name: UpdateCompany :one UPDATE companies diff --git a/docs/docs.go b/docs/docs.go index 2a56dac..b817783 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -805,6 +805,53 @@ const docTemplate = `{ } } }, + "/branch/{id}/cashier": { + "get": { + "description": "Gets branch cashiers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "branch" + ], + "summary": "Gets branch cashiers", + "parameters": [ + { + "type": "integer", + "description": "Branch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.GetCashierRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/branch/{id}/operation": { "get": { "description": "Gets branch operations", @@ -2756,6 +2803,50 @@ const docTemplate = `{ } } }, + "/user/delete/{id}": { + "delete": { + "description": "Delete a user by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/profile": { "get": { "security": [ @@ -3052,7 +3143,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.APIResponse" + "$ref": "#/definitions/handlers.UserProfileRes" } }, "400": { @@ -3076,6 +3167,52 @@ const docTemplate = `{ } } }, + "/user/suspend": { + "post": { + "description": "Suspend or unsuspend a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Suspend or unsuspend a user", + "parameters": [ + { + "description": "Suspend or unsuspend a user", + "name": "updateUserSuspend", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/wallet": { "get": { "security": [ @@ -3601,9 +3738,11 @@ const docTemplate = `{ 1, 2, 3, - 4 + 4, + 5 ], "x-enum-comments": { + "OUTCOME_STATUS_ERROR": "Half Win and Half Given Back", "OUTCOME_STATUS_HALF": "Half Win and Half Given Back", "OUTCOME_STATUS_VOID": "Give Back" }, @@ -3612,7 +3751,8 @@ const docTemplate = `{ "OUTCOME_STATUS_WIN", "OUTCOME_STATUS_LOSS", "OUTCOME_STATUS_VOID", - "OUTCOME_STATUS_HALF" + "OUTCOME_STATUS_HALF", + "OUTCOME_STATUS_ERROR" ] }, "domain.PaymentOption": { @@ -3660,10 +3800,18 @@ const docTemplate = `{ }, "domain.RandomBetReq": { "type": "object", + "required": [ + "branch_id", + "number_of_bets" + ], "properties": { "branch_id": { "type": "integer", "example": 1 + }, + "number_of_bets": { + "type": "integer", + "example": 1 } } }, @@ -4383,6 +4531,50 @@ const docTemplate = `{ } } }, + "handlers.GetCashierRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "handlers.ManagersRes": { "type": "object", "properties": { @@ -4733,6 +4925,34 @@ const docTemplate = `{ } } }, + "handlers.UpdateUserSuspendReq": { + "type": "object", + "required": [ + "suspended", + "user_id" + ], + "properties": { + "suspended": { + "type": "boolean", + "example": true + }, + "user_id": { + "type": "integer", + "example": 123 + } + } + }, + "handlers.UpdateUserSuspendRes": { + "type": "object", + "properties": { + "suspended": { + "type": "boolean" + }, + "user_id": { + "type": "integer" + } + } + }, "handlers.UpdateWalletActiveReq": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 66fa0cd..d4e8cfe 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -797,6 +797,53 @@ } } }, + "/branch/{id}/cashier": { + "get": { + "description": "Gets branch cashiers", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "branch" + ], + "summary": "Gets branch cashiers", + "parameters": [ + { + "type": "integer", + "description": "Branch ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.GetCashierRes" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/branch/{id}/operation": { "get": { "description": "Gets branch operations", @@ -2748,6 +2795,50 @@ } } }, + "/user/delete/{id}": { + "delete": { + "description": "Delete a user by their ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete user by ID", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/profile": { "get": { "security": [ @@ -3044,7 +3135,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/response.APIResponse" + "$ref": "#/definitions/handlers.UserProfileRes" } }, "400": { @@ -3068,6 +3159,52 @@ } } }, + "/user/suspend": { + "post": { + "description": "Suspend or unsuspend a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Suspend or unsuspend a user", + "parameters": [ + { + "description": "Suspend or unsuspend a user", + "name": "updateUserSuspend", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.UpdateUserSuspendRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/wallet": { "get": { "security": [ @@ -3593,9 +3730,11 @@ 1, 2, 3, - 4 + 4, + 5 ], "x-enum-comments": { + "OUTCOME_STATUS_ERROR": "Half Win and Half Given Back", "OUTCOME_STATUS_HALF": "Half Win and Half Given Back", "OUTCOME_STATUS_VOID": "Give Back" }, @@ -3604,7 +3743,8 @@ "OUTCOME_STATUS_WIN", "OUTCOME_STATUS_LOSS", "OUTCOME_STATUS_VOID", - "OUTCOME_STATUS_HALF" + "OUTCOME_STATUS_HALF", + "OUTCOME_STATUS_ERROR" ] }, "domain.PaymentOption": { @@ -3652,10 +3792,18 @@ }, "domain.RandomBetReq": { "type": "object", + "required": [ + "branch_id", + "number_of_bets" + ], "properties": { "branch_id": { "type": "integer", "example": 1 + }, + "number_of_bets": { + "type": "integer", + "example": 1 } } }, @@ -4375,6 +4523,50 @@ } } }, + "handlers.GetCashierRes": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_login": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "phone_number": { + "type": "string" + }, + "phone_verified": { + "type": "boolean" + }, + "role": { + "$ref": "#/definitions/domain.Role" + }, + "suspended": { + "type": "boolean" + }, + "suspended_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "handlers.ManagersRes": { "type": "object", "properties": { @@ -4725,6 +4917,34 @@ } } }, + "handlers.UpdateUserSuspendReq": { + "type": "object", + "required": [ + "suspended", + "user_id" + ], + "properties": { + "suspended": { + "type": "boolean", + "example": true + }, + "user_id": { + "type": "integer", + "example": 123 + } + } + }, + "handlers.UpdateUserSuspendRes": { + "type": "object", + "properties": { + "suspended": { + "type": "boolean" + }, + "user_id": { + "type": "integer" + } + } + }, "handlers.UpdateWalletActiveReq": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fee0fad..0387494 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -165,8 +165,10 @@ definitions: - 2 - 3 - 4 + - 5 type: integer x-enum-comments: + OUTCOME_STATUS_ERROR: Half Win and Half Given Back OUTCOME_STATUS_HALF: Half Win and Half Given Back OUTCOME_STATUS_VOID: Give Back x-enum-varnames: @@ -175,6 +177,7 @@ definitions: - OUTCOME_STATUS_LOSS - OUTCOME_STATUS_VOID - OUTCOME_STATUS_HALF + - OUTCOME_STATUS_ERROR domain.PaymentOption: enum: - 0 @@ -211,6 +214,12 @@ definitions: branch_id: example: 1 type: integer + number_of_bets: + example: 1 + type: integer + required: + - branch_id + - number_of_bets type: object domain.RawOddsByMarketID: properties: @@ -718,6 +727,35 @@ definitions: static_updated_at: type: string type: object + handlers.GetCashierRes: + properties: + created_at: + type: string + email: + type: string + email_verified: + type: boolean + first_name: + type: string + id: + type: integer + last_login: + type: string + last_name: + type: string + phone_number: + type: string + phone_verified: + type: boolean + role: + $ref: '#/definitions/domain.Role' + suspended: + type: boolean + suspended_at: + type: string + updated_at: + type: string + type: object handlers.ManagersRes: properties: created_at: @@ -963,6 +1001,25 @@ definitions: example: true type: boolean type: object + handlers.UpdateUserSuspendReq: + properties: + suspended: + example: true + type: boolean + user_id: + example: 123 + type: integer + required: + - suspended + - user_id + type: object + handlers.UpdateUserSuspendRes: + properties: + suspended: + type: boolean + user_id: + type: integer + type: object handlers.UpdateWalletActiveReq: properties: is_active: @@ -1658,6 +1715,37 @@ paths: summary: Gets bets by its branch id tags: - branch + /branch/{id}/cashier: + get: + consumes: + - application/json + description: Gets branch cashiers + parameters: + - description: Branch ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.GetCashierRes' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Gets branch cashiers + tags: + - branch /branch/{id}/operation: get: consumes: @@ -2940,6 +3028,35 @@ paths: summary: Check if phone number or email exist tags: - user + /user/delete/{id}: + delete: + consumes: + - application/json + description: Delete a user by their ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/response.APIResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Delete user by ID + tags: + - user /user/profile: get: consumes: @@ -3132,7 +3249,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/response.APIResponse' + $ref: '#/definitions/handlers.UserProfileRes' "400": description: Bad Request schema: @@ -3148,6 +3265,36 @@ paths: summary: Get user by id tags: - user + /user/suspend: + post: + consumes: + - application/json + description: Suspend or unsuspend a user + parameters: + - description: Suspend or unsuspend a user + in: body + name: updateUserSuspend + required: true + schema: + $ref: '#/definitions/handlers.UpdateUserSuspendReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.UpdateUserSuspendRes' + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Suspend or unsuspend a user + tags: + - user /user/wallet: get: consumes: diff --git a/gen/db/company.sql.go b/gen/db/company.sql.go index 13a1940..3c5a6b1 100644 --- a/gen/db/company.sql.go +++ b/gen/db/company.sql.go @@ -50,19 +50,19 @@ func (q *Queries) DeleteCompany(ctx context.Context, id int64) error { } const GetAllCompanies = `-- name: GetAllCompanies :many -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details ` -func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, error) { +func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesDetail, error) { rows, err := q.db.Query(ctx, GetAllCompanies) if err != nil { return nil, err } defer rows.Close() - var items []CompaniesWithWallet + var items []CompaniesDetail for rows.Next() { - var i CompaniesWithWallet + var i CompaniesDetail if err := rows.Scan( &i.ID, &i.Name, @@ -70,6 +70,9 @@ func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, e &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ); err != nil { return nil, err } @@ -82,14 +85,14 @@ func (q *Queries) GetAllCompanies(ctx context.Context) ([]CompaniesWithWallet, e } const GetCompanyByID = `-- name: GetCompanyByID :one -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details WHERE id = $1 ` -func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesWithWallet, error) { +func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesDetail, error) { row := q.db.QueryRow(ctx, GetCompanyByID, id) - var i CompaniesWithWallet + var i CompaniesDetail err := row.Scan( &i.ID, &i.Name, @@ -97,25 +100,28 @@ func (q *Queries) GetCompanyByID(ctx context.Context, id int64) (CompaniesWithWa &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ) return i, err } const SearchCompanyByName = `-- name: SearchCompanyByName :many -SELECT id, name, admin_id, wallet_id, balance, is_active -FROM companies_with_wallets +SELECT id, name, admin_id, wallet_id, balance, is_active, admin_first_name, admin_last_name, admin_phone_number +FROM companies_details WHERE name ILIKE '%' || $1 || '%' ` -func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) ([]CompaniesWithWallet, error) { +func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) ([]CompaniesDetail, error) { rows, err := q.db.Query(ctx, SearchCompanyByName, dollar_1) if err != nil { return nil, err } defer rows.Close() - var items []CompaniesWithWallet + var items []CompaniesDetail for rows.Next() { - var i CompaniesWithWallet + var i CompaniesDetail if err := rows.Scan( &i.ID, &i.Name, @@ -123,6 +129,9 @@ func (q *Queries) SearchCompanyByName(ctx context.Context, dollar_1 pgtype.Text) &i.WalletID, &i.Balance, &i.IsActive, + &i.AdminFirstName, + &i.AdminLastName, + &i.AdminPhoneNumber, ); err != nil { return nil, err } diff --git a/gen/db/models.go b/gen/db/models.go index 0cc5956..9b27432 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -146,13 +146,16 @@ type BranchOperation struct { UpdatedAt pgtype.Timestamp `json:"updated_at"` } -type CompaniesWithWallet struct { - ID int64 `json:"id"` - Name string `json:"name"` - AdminID int64 `json:"admin_id"` - WalletID int64 `json:"wallet_id"` - Balance int64 `json:"balance"` - IsActive bool `json:"is_active"` +type CompaniesDetail struct { + ID int64 `json:"id"` + Name string `json:"name"` + AdminID int64 `json:"admin_id"` + WalletID int64 `json:"wallet_id"` + Balance int64 `json:"balance"` + IsActive bool `json:"is_active"` + AdminFirstName string `json:"admin_first_name"` + AdminLastName string `json:"admin_last_name"` + AdminPhoneNumber pgtype.Text `json:"admin_phone_number"` } type Company struct { diff --git a/internal/domain/bet.go b/internal/domain/bet.go index e8f4ee2..d681bb8 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -97,7 +97,8 @@ type CreateBetReq struct { } type RandomBetReq struct { - BranchID int64 `json:"branch_id" validate:"required" example:"1"` + BranchID int64 `json:"branch_id" validate:"required" example:"1"` + NumberOfBets int64 `json:"number_of_bets" validate:"required" example:"1"` } type CreateBetRes struct { diff --git a/internal/domain/company.go b/internal/domain/company.go index 9a05e4c..f0a6420 100644 --- a/internal/domain/company.go +++ b/internal/domain/company.go @@ -11,12 +11,15 @@ type Company struct { } type GetCompany struct { - ID int64 - Name string - AdminID int64 - WalletID int64 - WalletBalance Currency - IsWalletActive bool + ID int64 + Name string + AdminID int64 + AdminFirstName string + AdminLastName string + AdminPhoneNumber string + WalletID int64 + WalletBalance Currency + IsWalletActive bool } type CreateCompany struct { diff --git a/internal/domain/league.go b/internal/domain/league.go index 8f63445..a4a9cc2 100644 --- a/internal/domain/league.go +++ b/internal/domain/league.go @@ -26,11 +26,14 @@ var SupportedLeagues = []int64{ // Basketball 173998768, //NBA 10041830, //NBA + 10049984, //WNBA + 10037165, //German Bundesliga + 10036608, //Italian Lega 1 + 10040795, //EuroLeague // Ice Hockey 10037477, //NHL 10037447, //AHL 10069385, //IIHF World Championship - 10040795, //EuroLeague } diff --git a/internal/domain/result.go b/internal/domain/result.go index fc3a621..3400e4e 100644 --- a/internal/domain/result.go +++ b/internal/domain/result.go @@ -64,3 +64,21 @@ func (o *OutcomeStatus) String() string { return "UNKNOWN" } } + +type TimeStatus int32 + +const ( + TIME_STATUS_NOT_STARTED TimeStatus = 0 + TIME_STATUS_IN_PLAY TimeStatus = 1 + TIME_STATUS_TO_BE_FIXED TimeStatus = 2 + TIME_STATUS_ENDED TimeStatus = 3 + TIME_STATUS_POSTPONED TimeStatus = 4 + TIME_STATUS_CANCELLED TimeStatus = 5 + TIME_STATUS_WALKOVER TimeStatus = 6 + TIME_STATUS_INTERRUPTED TimeStatus = 7 + TIME_STATUS_ABANDONED TimeStatus = 8 + TIME_STATUS_RETIRED TimeStatus = 9 + TIME_STATUS_SUSPENDED TimeStatus = 10 + TIME_STATUS_DECIDED_BY_FA TimeStatus = 11 + TIME_STATUS_REMOVED TimeStatus = 99 +) diff --git a/internal/domain/resultres.go b/internal/domain/resultres.go index b69a6a9..8a17f24 100644 --- a/internal/domain/resultres.go +++ b/internal/domain/resultres.go @@ -43,6 +43,7 @@ type FootballResultResponse struct { Stats struct { Attacks []string `json:"attacks"` Corners []string `json:"corners"` + HalfTimeCorners []string `json:"corner_h"` DangerousAttacks []string `json:"dangerous_attacks"` Goals []string `json:"goals"` OffTarget []string `json:"off_target"` @@ -94,7 +95,7 @@ type BasketballResultResponse struct { Possession []string `json:"possession"` SuccessAttempts []string `json:"success_attempts"` TimeSpendInLead []string `json:"timespent_inlead"` - Timeuts []string `json:"time_outs"` + TimeOuts []string `json:"time_outs"` } `json:"stats"` Extra struct { HomePos string `json:"home_pos"` @@ -104,7 +105,7 @@ type BasketballResultResponse struct { NumberOfPeriods string `json:"numberofperiods"` PeriodLength string `json:"periodlength"` StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` + Length int `json:"length"` Round string `json:"round"` } `json:"extra"` Events []map[string]string `json:"events"` @@ -142,7 +143,7 @@ type IceHockeyResultResponse struct { NumberOfPeriods string `json:"numberofperiods"` PeriodLength string `json:"periodlength"` StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` + Length int `json:"length"` Round string `json:"round"` } `json:"extra"` Events []map[string]string `json:"events"` diff --git a/internal/domain/sportmarket.go b/internal/domain/sportmarket.go index 360afee..ded71bb 100644 --- a/internal/domain/sportmarket.go +++ b/internal/domain/sportmarket.go @@ -3,12 +3,12 @@ package domain type FootballMarket int64 const ( - FOOTBALL_FULL_TIME_RESULT FootballMarket = 40 //"full_time_result" - FOOTBALL_DOUBLE_CHANCE FootballMarket = 10114 //"double_chance" - FOOTBALL_GOALS_OVER_UNDER FootballMarket = 981 //"goals_over_under" - FOOTBALL_CORRECT_SCORE FootballMarket = 43 //"correct_score" - FOOTBALL_ASIAN_HANDICAP FootballMarket = 938 //"asian_handicap" - FOOTBALL_GOAL_LINE FootballMarket = 10143 //"goal_line" + FOOTBALL_FULL_TIME_RESULT FootballMarket = 40 //"full_time_result" + FOOTBALL_DOUBLE_CHANCE FootballMarket = 10114 //"double_chance" + FOOTBALL_GOALS_OVER_UNDER FootballMarket = 981 //"goals_over_under" + FOOTBALL_CORRECT_SCORE FootballMarket = 43 //"correct_score" + FOOTBALL_ASIAN_HANDICAP FootballMarket = 938 //"asian_handicap" + FOOTBALL_GOAL_LINE FootballMarket = 10143 //"goal_line" FOOTBALL_HALF_TIME_RESULT FootballMarket = 1579 //"half_time_result" FOOTBALL_FIRST_HALF_ASIAN_HANDICAP FootballMarket = 50137 //"1st_half_asian_handicap" @@ -17,7 +17,14 @@ const ( FOOTBALL_GOALS_ODD_EVEN FootballMarket = 10111 //"goals_odd_even" FOOTBALL_DRAW_NO_BET FootballMarket = 10544 //"draw_no_bet" - + FOOTBALL_CORNERS FootballMarket = 760 //"corners" + FOOTBALL_CORNERS_TWO_WAY FootballMarket = 10235 //"corners_2_way" + FOOTBALL_FIRST_HALF_CORNERS FootballMarket = 10539 //"first_half_corners" + FOOTBALL_ASIAN_TOTAL_CORNERS FootballMarket = 10164 //"asian_total_corners" + FOOTBALL_FIRST_HALF_ASIAN_CORNERS FootballMarket = 10233 //"1st_half_asian_corners" + FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN FootballMarket = 10206 //"1st_half_goals_odd_even" + FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN FootballMarket = 50433 //"2nd_half_goals_odd_even" + ) type BasketBallMarket int64 @@ -99,19 +106,26 @@ const ( var SupportedMarkets = map[int64]bool{ // Football Markets - int64(FOOTBALL_FULL_TIME_RESULT): true, //"full_time_result" - int64(FOOTBALL_DOUBLE_CHANCE): true, //"double_chance" - int64(FOOTBALL_GOALS_OVER_UNDER): true, //"goals_over_under" - int64(FOOTBALL_CORRECT_SCORE): true, //"correct_score" - int64(FOOTBALL_ASIAN_HANDICAP): true, //"asian_handicap" - int64(FOOTBALL_GOAL_LINE): true, //"goal_line" - int64(FOOTBALL_HALF_TIME_RESULT): true, //"half_time_result" - int64(FOOTBALL_FIRST_HALF_ASIAN_HANDICAP): true, //"1st_half_asian_handicap" - int64(FOOTBALL_FIRST_HALF_GOAL_LINE): true, //"1st_half_goal_line" - int64(FOOTBALL_FIRST_TEAM_TO_SCORE): true, //"first_team_to_score" - int64(FOOTBALL_GOALS_ODD_EVEN): true, //"goals_odd_even" - int64(FOOTBALL_DRAW_NO_BET): true, //"draw_no_bet" - + int64(FOOTBALL_FULL_TIME_RESULT): true, //"full_time_result" + int64(FOOTBALL_DOUBLE_CHANCE): true, //"double_chance" + int64(FOOTBALL_GOALS_OVER_UNDER): true, //"goals_over_under" + int64(FOOTBALL_CORRECT_SCORE): true, //"correct_score" + int64(FOOTBALL_ASIAN_HANDICAP): true, //"asian_handicap" + int64(FOOTBALL_GOAL_LINE): true, //"goal_line" + int64(FOOTBALL_HALF_TIME_RESULT): true, //"half_time_result" + int64(FOOTBALL_FIRST_HALF_ASIAN_HANDICAP): true, //"1st_half_asian_handicap" + int64(FOOTBALL_FIRST_HALF_GOAL_LINE): true, //"1st_half_goal_line" + int64(FOOTBALL_FIRST_TEAM_TO_SCORE): true, //"first_team_to_score" + int64(FOOTBALL_GOALS_ODD_EVEN): true, //"goals_odd_even" + int64(FOOTBALL_DRAW_NO_BET): true, //"draw_no_bet" + int64(FOOTBALL_CORNERS): true, + int64(FOOTBALL_CORNERS_TWO_WAY): true, + int64(FOOTBALL_FIRST_HALF_CORNERS): true, + int64(FOOTBALL_ASIAN_TOTAL_CORNERS): true, + int64(FOOTBALL_FIRST_HALF_ASIAN_CORNERS): true, + int64(FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN): true, + int64(FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN): true, + // Basketball Markets int64(BASKETBALL_GAME_LINES): true, int64(BASKETBALL_RESULT_AND_BOTH_TEAMS_TO_SCORE_X_POINTS): true, diff --git a/internal/repository/company.go b/internal/repository/company.go index 8f3fe1a..d9b8e06 100644 --- a/internal/repository/company.go +++ b/internal/repository/company.go @@ -25,14 +25,17 @@ func convertDBCompany(dbCompany dbgen.Company) domain.Company { } } -func convertDBCompanyWithWallet(dbCompany dbgen.CompaniesWithWallet) domain.GetCompany { +func convertDBCompanyDetails(dbCompany dbgen.CompaniesDetail) domain.GetCompany { return domain.GetCompany{ - ID: dbCompany.ID, - Name: dbCompany.Name, - AdminID: dbCompany.AdminID, - WalletID: dbCompany.WalletID, - WalletBalance: domain.Currency(dbCompany.Balance), - IsWalletActive: dbCompany.IsActive, + ID: dbCompany.ID, + Name: dbCompany.Name, + AdminID: dbCompany.AdminID, + WalletID: dbCompany.WalletID, + WalletBalance: domain.Currency(dbCompany.Balance), + IsWalletActive: dbCompany.IsActive, + AdminFirstName: dbCompany.AdminFirstName, + AdminLastName: dbCompany.AdminLastName, + AdminPhoneNumber: dbCompany.AdminPhoneNumber.String, } } @@ -74,7 +77,7 @@ func (s *Store) GetAllCompanies(ctx context.Context) ([]domain.GetCompany, error var companies []domain.GetCompany = make([]domain.GetCompany, 0, len(dbCompanies)) for _, dbCompany := range dbCompanies { - companies = append(companies, convertDBCompanyWithWallet(dbCompany)) + companies = append(companies, convertDBCompanyDetails(dbCompany)) } return companies, nil @@ -92,7 +95,7 @@ func (s *Store) SearchCompanyByName(ctx context.Context, name string) ([]domain. var companies []domain.GetCompany = make([]domain.GetCompany, 0, len(dbCompanies)) for _, dbCompany := range dbCompanies { - companies = append(companies, convertDBCompanyWithWallet(dbCompany)) + companies = append(companies, convertDBCompanyDetails(dbCompany)) } return companies, nil } @@ -103,7 +106,7 @@ func (s *Store) GetCompanyByID(ctx context.Context, id int64) (domain.GetCompany if err != nil { return domain.GetCompany{}, err } - return convertDBCompanyWithWallet(dbCompany), nil + return convertDBCompanyDetails(dbCompany), nil } func (s *Store) UpdateCompany(ctx context.Context, company domain.UpdateCompany) (domain.Company, error) { diff --git a/internal/repository/event.go b/internal/repository/event.go index 895a963..904ca2c 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -118,7 +118,7 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcoming } func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { - + events, err := s.queries.GetPaginatedUpcomingEvents(ctx, dbgen.GetPaginatedUpcomingEventsParams{ LeagueID: pgtype.Text{ String: leagueID.Value, @@ -128,7 +128,7 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val String: sportID.Value, Valid: sportID.Valid, }, - Limit: pgtype.Int4{ + Limit: pgtype.Int4{ Int32: int32(limit.Value), Valid: limit.Valid, }, diff --git a/internal/repository/user.go b/internal/repository/user.go index 3c0c910..c2aa930 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -230,6 +230,22 @@ func (s *Store) UpdateUserCompany(ctx context.Context, id int64, companyID int64 } return nil } + +func (s *Store) UpdateUserSuspend(ctx context.Context, id int64, status bool) error { + err := s.queries.SuspendUser(ctx, dbgen.SuspendUserParams{ + ID: id, + Suspended: status, + SuspendedAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }) + if err != nil { + return err + } + return nil +} + func (s *Store) DeleteUser(ctx context.Context, id int64) error { err := s.queries.DeleteUser(ctx, id) if err != nil { diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 5bc392d..a644021 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -362,7 +362,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le // TODO: Add the option of passing number of created events var selectedUpcomingEvents []domain.UpcomingEvent - numEventsPerBet := random.Intn(4) + 1 //Eliminate the option of 0 + numEventsPerBet := min(random.Intn(4)+1, len(events)) //Eliminate the option of 0 for i := 0; i < int(numEventsPerBet); i++ { randomIndex := random.Intn(len(events)) @@ -371,7 +371,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le } - s.logger.Info("Generating random bet events", "selectedUpcomingEvents", len(selectedUpcomingEvents)) + // s.logger.Info("Generating random bet events", "selectedUpcomingEvents", len(selectedUpcomingEvents)) // Get market and odds for that var randomOdds []domain.CreateBetOutcome @@ -395,7 +395,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le return domain.CreateBetRes{}, ErrGenerateRandomOutcome } - s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) + // s.logger.Info("Generated Random bet Outcome", "randomOdds", len(randomOdds)) var cashoutID string @@ -491,9 +491,9 @@ func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domai status = betOutcome.Status case domain.OUTCOME_STATUS_WIN: if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { - status = domain.OUTCOME_STATUS_HALF + status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_HALF { - status = domain.OUTCOME_STATUS_VOID + status = domain.OUTCOME_STATUS_HALF } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { status = domain.OUTCOME_STATUS_WIN } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { @@ -509,16 +509,18 @@ func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domai } else if betOutcome.Status == domain.OUTCOME_STATUS_WIN { status = domain.OUTCOME_STATUS_LOSS } else if betOutcome.Status == domain.OUTCOME_STATUS_VOID { - status = domain.OUTCOME_STATUS_VOID + status = domain.OUTCOME_STATUS_LOSS } else { status = domain.OUTCOME_STATUS_ERROR } case domain.OUTCOME_STATUS_VOID: if betOutcome.Status == domain.OUTCOME_STATUS_VOID || betOutcome.Status == domain.OUTCOME_STATUS_WIN || - betOutcome.Status == domain.OUTCOME_STATUS_LOSS || betOutcome.Status == domain.OUTCOME_STATUS_HALF { status = domain.OUTCOME_STATUS_VOID + } else if betOutcome.Status == domain.OUTCOME_STATUS_LOSS { + status = domain.OUTCOME_STATUS_LOSS + } else { status = domain.OUTCOME_STATUS_ERROR } diff --git a/internal/services/event/service.go b/internal/services/event/service.go index f344e2c..37781d1 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -100,7 +100,7 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { func (s *service) FetchUpcomingEvents(ctx context.Context) error { // sportIDs := []int{1, 18, 17} - sportIDs := []int{18} + sportIDs := []int{18, 17} for _, sportID := range sportIDs { var totalPages int = 1 @@ -142,6 +142,7 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { ID string `json:"id"` Name string `json:"name"` } `json:"away"` + } `json:"results"` } if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { @@ -164,7 +165,7 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } if !slices.Contains(domain.SupportedLeagues, leagueID) { - + fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) skippedLeague = append(skippedLeague, ev.League.Name) continue } @@ -172,7 +173,7 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { event := domain.UpcomingEvent{ ID: ev.ID, SportID: ev.SportID, - MatchName: ev.Home.Name, + MatchName: "", HomeTeam: ev.Home.Name, AwayTeam: "", // handle nil safely HomeTeamID: ev.Home.ID, @@ -188,6 +189,7 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { if ev.Away != nil { event.AwayTeam = ev.Away.Name event.AwayTeamID = ev.Away.ID + event.MatchName = ev.Home.Name + " vs " + ev.Away.Name } err = s.store.SaveUpcomingEvent(ctx, event) @@ -234,7 +236,7 @@ func (s *service) GetExpiredUpcomingEvents(ctx context.Context) ([]domain.Upcomi return s.store.GetExpiredUpcomingEvents(ctx) } -func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error){ +func (s *service) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.ValidInt64, offset domain.ValidInt64, leagueID domain.ValidString, sportID domain.ValidString, firstStartTime domain.ValidTime, lastStartTime domain.ValidTime) ([]domain.UpcomingEvent, int64, error) { return s.store.GetPaginatedUpcomingEvents(ctx, limit, offset, leagueID, sportID, firstStartTime, lastStartTime) } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index 5d6f1d0..a2c4016 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -246,7 +246,7 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName var marketIDint int err := json.Unmarshal(market.ID, &marketIDint) if err != nil { - s.logger.Error("Invalid market id") + s.logger.Error("Invalid market id", "marketID", marketIDstr, "marketName", market.Name) errs = append(errs, err) } } diff --git a/internal/services/result/eval.go b/internal/services/result/eval.go index 93f3d64..c9502d0 100644 --- a/internal/services/result/eval.go +++ b/internal/services/result/eval.go @@ -85,6 +85,8 @@ func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.Outcom return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("❌ mutli outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) } + // fmt.Printf("| Multi Outcome | %v -> %v \n", outcome.String(), secondOutcome.String()) + switch outcome { case domain.OUTCOME_STATUS_PENDING: return secondOutcome, nil @@ -94,7 +96,7 @@ func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.Outcom } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { return domain.OUTCOME_STATUS_LOSS, nil } else if secondOutcome == domain.OUTCOME_STATUS_HALF { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { return domain.OUTCOME_STATUS_HALF, nil } else { @@ -107,14 +109,14 @@ func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.Outcom secondOutcome == domain.OUTCOME_STATUS_HALF { return domain.OUTCOME_STATUS_LOSS, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil } else { fmt.Printf("❌ multi outcome: %v -> %v \n", outcome.String(), secondOutcome.String()) return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid multi outcome") } case domain.OUTCOME_STATUS_VOID: if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_LOSS { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID || secondOutcome == domain.OUTCOME_STATUS_HALF { return domain.OUTCOME_STATUS_VOID, nil } else { @@ -123,7 +125,7 @@ func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.Outcom } case domain.OUTCOME_STATUS_HALF: if secondOutcome == domain.OUTCOME_STATUS_WIN || secondOutcome == domain.OUTCOME_STATUS_HALF { - return domain.OUTCOME_STATUS_HALF, nil + return domain.OUTCOME_STATUS_VOID, nil } else if secondOutcome == domain.OUTCOME_STATUS_LOSS { return domain.OUTCOME_STATUS_LOSS, nil } else if secondOutcome == domain.OUTCOME_STATUS_VOID { @@ -139,6 +141,8 @@ func checkMultiOutcome(outcome domain.OutcomeStatus, secondOutcome domain.Outcom } // Asian Handicap betting is a type of betting that eliminates the possibility of a draw by giving one team a virtual advantage or disadvantage. +// When the handicap has two values like "+0.5, +1.0" or "-0.5, -1.0", then it a multi outcome bet +// . // // { // "id": "548319135", @@ -178,26 +182,32 @@ func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away i if err != nil { return domain.OUTCOME_STATUS_ERROR, err } + continue } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { return domain.OUTCOME_STATUS_ERROR, err } + continue } else if adjustedHomeScore < adjustedAwayScore { if outcome.OddHeader == "2" { newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_WIN) if err != nil { return domain.OUTCOME_STATUS_ERROR, err } + continue } newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_LOSS) if err != nil { return domain.OUTCOME_STATUS_ERROR, err } - } - newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) - if err != nil { - return domain.OUTCOME_STATUS_ERROR, err + continue + } else if adjustedHomeScore == adjustedAwayScore { + newOutcome, err = checkMultiOutcome(newOutcome, domain.OUTCOME_STATUS_VOID) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, err + } + continue } } return newOutcome, nil @@ -306,24 +316,60 @@ func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away in return domain.OUTCOME_STATUS_LOSS, nil } +func evaluateTeamOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + + switch outcome.OddHeader { + case "1": + if outcome.OddHandicap == "Odd" { + if score.Home%2 == 1 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHandicap == "Even" { + if score.Home%2 == 0 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd handicap: %s", outcome.OddHandicap) + } + case "2": + if outcome.OddHandicap == "Odd" { + if score.Away%2 == 1 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHandicap == "Even" { + if score.Away%2 == 0 { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd handicap: %s", outcome.OddHandicap) + } + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + + } +} + // Double Chance betting is a type of bet where the bettor predicts two of the three possible outcomes of a match. func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { isHomeWin := score.Home > score.Away isDraw := score.Home == score.Away isAwayWin := score.Away > score.Home - switch outcome.OddName { - case "1 or Draw", (outcome.HomeTeamName + " or " + "Draw"): + case "1 or Draw", (outcome.HomeTeamName + " or " + "Draw"), ("Draw" + " or " + outcome.HomeTeamName): if isHomeWin || isDraw { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil - case "Draw or 2", ("Draw" + " or " + outcome.AwayTeamName): + case "Draw or 2", ("Draw" + " or " + outcome.AwayTeamName), (outcome.AwayTeamName + " or " + "Draw"): if isDraw || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } return domain.OUTCOME_STATUS_LOSS, nil - case "1 or 2", (outcome.HomeTeamName + " or " + outcome.AwayTeamName): + case "1 or 2", (outcome.HomeTeamName + " or " + outcome.AwayTeamName), (outcome.AwayTeamName + " or " + outcome.HomeTeamName): if isHomeWin || isAwayWin { return domain.OUTCOME_STATUS_WIN, nil } @@ -346,6 +392,34 @@ func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int } return domain.OUTCOME_STATUS_LOSS, nil } +func evaluateCorners(outcome domain.BetOutcome, corners struct{ Home, Away int }) (domain.OutcomeStatus, error) { + + totalCorners := corners.Home + corners.Away + threshold, err := strconv.ParseFloat(outcome.OddName, 10) + if err != nil { + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + switch outcome.OddHeader { + case "Over": + if totalCorners > int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "Under": + if totalCorners < int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "Exactly": + if totalCorners == int(threshold) { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + // Basketball evaluations // Game Lines is an aggregate of money line, spread and total betting markets in one @@ -715,6 +789,30 @@ func evaluateHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ return domain.OUTCOME_STATUS_LOSS, nil } +// Team With Highest Scoring Quarter betting is a type of bet where the bettor predicts which team will have the highest score in a specific quarter. +func evaluateTeamWithHighestScoringQuarter(outcome domain.BetOutcome, firstScore struct{ Home, Away int }, secondScore struct{ Home, Away int }, thirdScore struct{ Home, Away int }, fourthScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { + homeTeamHighestQuarter := max(firstScore.Home, secondScore.Home, thirdScore.Home, fourthScore.Home) + awayTeamHighestQuarter := max(firstScore.Away, secondScore.Away, thirdScore.Away, fourthScore.Away) + + switch outcome.OddName { + case "1": + if homeTeamHighestQuarter > awayTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + case "2": + if awayTeamHighestQuarter > homeTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + case "Tie": + if homeTeamHighestQuarter == awayTeamHighestQuarter { + return domain.OUTCOME_STATUS_WIN, nil + } + default: + return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName) + } + return domain.OUTCOME_STATUS_LOSS, nil +} + // Handicap and Total betting is a combination of spread betting and total points betting // where the bettor predicts the outcome of a match with a point spread and the total number of points scored is over or under a specified number. func evaluateHandicapAndTotal(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { diff --git a/internal/services/result/service.go b/internal/services/result/service.go index d7cc80a..77d3a2e 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -34,8 +34,9 @@ func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger, } } -type ResultCheck struct { -} +var ( + ErrEventIsNotActive = fmt.Errorf("Event has been cancelled or postponed") +) func (s *Service) FetchAndProcessResults(ctx context.Context) error { // TODO: Optimize this because there could be many bet outcomes for the same odd @@ -72,7 +73,7 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { if outcome.Expires.After(time.Now()) { isDeleted = false - s.logger.Info("Outcome is not expired yet", "event_id", event.ID, "outcome_id", outcome.ID) + s.logger.Warn("Outcome is not expired yet", "event_id", event.ID, "outcome_id", outcome.ID) continue } @@ -85,6 +86,10 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { // TODO: optimize this because the result is being fetched for each outcome which will have the same event id but different market id result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { + if err == ErrEventIsNotActive { + s.logger.Warn("Event is not active", "event_id", outcome.EventID, "error", err) + continue + } fmt.Printf("❌ failed to parse 🎲 outcomes '%v' for event %v(%v) (%d/%d) \n", outcome.MarketName, event.HomeTeam+" "+event.AwayTeam, event.ID, @@ -123,13 +128,14 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { } continue } - fmt.Printf("🧾 Updating bet status for event %v (%d/%d) to %v\n", event.ID, j+1, len(outcomes), status.String()) + fmt.Printf("🧾 Updating bet %v - event %v (%d/%d) to %v\n", outcome.BetID, event.ID, j+1, len(outcomes), status.String()) err = s.betSvc.UpdateStatus(ctx, outcome.BetID, status) if err != nil { s.logger.Error("Failed to update bet status", "event id", outcome.EventID, "error", err) continue } - fmt.Printf("✅ Successfully updated 🎫 Bet for event %v(%v) (%d/%d) \n", + fmt.Printf("✅ Successfully updated 🎫 Bet %v - event %v(%v) (%d/%d) \n", + outcome.BetID, event.HomeTeam+" "+event.AwayTeam, event.ID, j+1, len(outcomes)) @@ -273,6 +279,34 @@ func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, spo } +func (s *Service) parseTimeStatus(timeStatusStr string) (bool, error) { + timeStatusParsed, err := strconv.ParseInt(strings.TrimSpace(timeStatusStr), 10, 64) + if err != nil { + s.logger.Error("Failed to parse time status", "time_status", timeStatusStr, "error", err) + return false, fmt.Errorf("failed to parse time status: %w", err) + } + timeStatus := domain.TimeStatus(timeStatusParsed) + + switch timeStatus { + case domain.TIME_STATUS_NOT_STARTED, domain.TIME_STATUS_IN_PLAY, domain.TIME_STATUS_TO_BE_FIXED, domain.TIME_STATUS_ENDED: + return true, nil + case domain.TIME_STATUS_POSTPONED, + domain.TIME_STATUS_CANCELLED, + domain.TIME_STATUS_WALKOVER, + domain.TIME_STATUS_INTERRUPTED, + domain.TIME_STATUS_ABANDONED, + domain.TIME_STATUS_RETIRED, + domain.TIME_STATUS_SUSPENDED, + domain.TIME_STATUS_DECIDED_BY_FA, + domain.TIME_STATUS_REMOVED: + return false, nil + default: + s.logger.Error("Invalid time status", "time_status", timeStatus) + return false, fmt.Errorf("invalid time status: %d", timeStatus) + } + +} + func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var fbResp domain.FootballResultResponse if err := json.Unmarshal(resultRes, &fbResp); err != nil { @@ -280,16 +314,24 @@ func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marke return domain.CreateResult{}, err } result := fbResp - if result.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + + isEventActive, err := s.parseTimeStatus(result.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } finalScore := parseSS(result.SS) - firstHalfScore := parseSS(fmt.Sprintf("%s-%s", result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away)) + firstHalfScore := parseScore(result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away) + secondHalfScore := parseScore(result.Scores.SecondHalf.Home, result.Scores.SecondHalf.Away) corners := parseStats(result.Stats.Corners) - status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, corners, result.Events) + halfTimeCorners := parseStats(result.Stats.HalfTimeCorners) + status, err := s.evaluateFootballOutcome(outcome, finalScore, firstHalfScore, secondHalfScore, corners, halfTimeCorners, result.Events) if err != nil { s.logger.Error("Failed to evaluate football outcome", "event_id", eventID, "market_id", marketID, "error", err) return domain.CreateResult{}, err @@ -309,12 +351,17 @@ func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marke func (s *Service) parseBasketball(response json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var basketBallRes domain.BasketballResultResponse if err := json.Unmarshal(response, &basketBallRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) + s.logger.Error("Failed to unmarshal basketball result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } - if basketBallRes.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + isEventActive, err := s.parseTimeStatus(basketBallRes.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } status, err := s.evaluateBasketballOutcome(outcome, basketBallRes) @@ -337,12 +384,17 @@ func (s *Service) parseBasketball(response json.RawMessage, eventID, oddID, mark func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { var iceHockeyRes domain.IceHockeyResultResponse if err := json.Unmarshal(response, &iceHockeyRes); err != nil { - s.logger.Error("Failed to unmarshal football result", "event_id", eventID, "error", err) + s.logger.Error("Failed to unmarshal ice hockey result", "event_id", eventID, "error", err) return domain.CreateResult{}, err } - if iceHockeyRes.TimeStatus != "3" { - s.logger.Warn("Match not yet completed", "event_id", eventID) - return domain.CreateResult{}, fmt.Errorf("match not yet completed") + isEventActive, err := s.parseTimeStatus(iceHockeyRes.TimeStatus) + if err != nil { + s.logger.Error("Failed to parse time status", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if !isEventActive { + s.logger.Warn("Event is not active", "event_id", eventID) + return domain.CreateResult{}, ErrEventIsNotActive } status, err := s.evaluateIceHockeyOutcome(outcome, iceHockeyRes) @@ -388,7 +440,10 @@ func parseStats(stats []string) struct{ Home, Away int } { } // evaluateOutcome determines the outcome status based on market type and odd -func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, firstHalfScore struct{ Home, Away int }, corners struct{ Home, Away int }, events []map[string]string) (domain.OutcomeStatus, error) { +func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, + firstHalfScore struct{ Home, Away int }, secondHalfScore struct{ Home, Away int }, + corners struct{ Home, Away int }, halfTimeCorners struct{ Home, Away int }, + events []map[string]string) (domain.OutcomeStatus, error) { if !domain.SupportedMarkets[outcome.MarketID] { s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) @@ -420,6 +475,21 @@ func (s *Service) evaluateFootballOutcome(outcome domain.BetOutcome, finalScore, return evaluateDoubleChance(outcome, finalScore) case int64(domain.FOOTBALL_DRAW_NO_BET): return evaluateDrawNoBet(outcome, finalScore) + case int64(domain.FOOTBALL_CORNERS): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_CORNERS_TWO_WAY): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_FIRST_HALF_CORNERS): + return evaluateCorners(outcome, halfTimeCorners) + case int64(domain.FOOTBALL_ASIAN_TOTAL_CORNERS): + return evaluateCorners(outcome, corners) + case int64(domain.FOOTBALL_FIRST_HALF_ASIAN_CORNERS): + return evaluateCorners(outcome, halfTimeCorners) + case int64(domain.FOOTBALL_FIRST_HALF_GOALS_ODD_EVEN): + return evaluateGoalsOddEven(outcome, firstHalfScore) + case int64(domain.FOOTBALL_SECOND_HALF_GOALS_ODD_EVEN): + return evaluateGoalsOddEven(outcome, secondHalfScore) + default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) @@ -456,7 +526,9 @@ func (s *Service) evaluateBasketballOutcome(outcome domain.BetOutcome, res domai case int64(domain.BASKETBALL_GAME_TOTAL_ODD_EVEN): return evaluateGoalsOddEven(outcome, finalScore) case int64(domain.BASKETBALL_TEAM_TOTALS): - return evaluateGoalsOddEven(outcome, finalScore) + return evaluateTeamTotal(outcome, finalScore) + case int64(domain.BASKETBALL_TEAM_TOTAL_ODD_EVEN): + return evaluateTeamOddEven(outcome, finalScore) case int64(domain.BASKETBALL_FIRST_HALF): return evaluateGameLines(outcome, firstHalfScore) @@ -487,6 +559,11 @@ func (s *Service) evaluateBasketballOutcome(outcome domain.BetOutcome, res domai return evaluateDoubleChance(outcome, firstQuarter) case int64(domain.BASKETBALL_HIGHEST_SCORING_QUARTER): return evaluateHighestScoringQuarter(outcome, firstQuarter, secondQuarter, thirdQuarter, fourthQuarter) + case int64(domain.BASKETBALL_FIRST_QUARTER_RESULT_AND_TOTAL): + return evaluateResultAndTotal(outcome, firstQuarter) + + case int64(domain.BASKETBALL_TEAM_WITH_HIGHEST_SCORING_QUARTER): + return evaluateTeamWithHighestScoringQuarter(outcome, firstQuarter, secondQuarter, thirdQuarter, fourthQuarter) default: s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 3dfa77e..c7d1bfb 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -15,6 +15,7 @@ type UserStore interface { GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error UpdateUserCompany(ctx context.Context, id int64, companyID int64) error + UpdateUserSuspend(ctx context.Context, id int64, status bool) error DeleteUser(ctx context.Context, id int64) error CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) GetUserByEmail(ctx context.Context, email string) (domain.User, error) diff --git a/internal/services/user/user.go b/internal/services/user/user.go index 225ecc6..a9d303e 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -20,7 +20,11 @@ func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) err func (s *Service) UpdateUserCompany(ctx context.Context, id int64, companyID int64) error { // update user return s.userStore.UpdateUserCompany(ctx, id, companyID) +} +func (s *Service) UpdateUserSuspend(ctx context.Context, id int64, status bool) error { + // update user + return s.userStore.UpdateUserSuspend(ctx, id, status) } func (s *Service) GetUserByID(ctx context.Context, id int64) (domain.User, error) { return s.userStore.GetUserByID(ctx, id) diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index e9bca42..1bbefe7 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -21,53 +21,24 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - // { - // spec: "0 0 * * * *", // Every 1 hour - // task: func() { - // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - // log.Printf("FetchUpcomingEvents error: %v", err) - // } - // }, - // }, - - // { - // spec: "*/5 * * * * *", // Every 5 seconds - // task: func() { - // if err := eventService.FetchLiveEvents(context.Background()); err != nil { - // log.Printf("FetchLiveEvents error: %v", err) - // } - // }, - // }, - // { - // spec: "0 */15 * * * *", // Every 15 minutes - // task: func() { - // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - // log.Printf("FetchNonLiveOdds error: %v", err) - // } - // }, - // }, - // { - // spec: "0 */15 * * * *", - // task: func() { - // log.Println("Fetching results for upcoming events...") - - // upcomingEvents, err := eventService.GetAllUpcomingEvents(context.Background()) - // if err != nil { - // log.Printf("Failed to fetch upcoming events: %v", err) - // return - // } - - // for _, event := range upcomingEvents { - // if err := resultService.FetchAndStoreResult(context.Background(), event.ID); err != nil { - // log.Printf(" Failed to fetch/store result for event %s: %v", event.ID, err) - // } else { - // log.Printf(" Successfully stored result for event %s", event.ID) - // } - // } - // }, - // }, { - spec: "0 */15 * * * *", + spec: "0 0 * * * *", // Every 1 hour + task: func() { + if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + log.Printf("FetchUpcomingEvents error: %v", err) + } + }, + }, + { + spec: "0 */15 * * * *", // Every 15 minutes + task: func() { + if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + log.Printf("FetchNonLiveOdds error: %v", err) + } + }, + }, + { + spec: "0 */15 * * * *", // Every 15 Minutes task: func() { log.Println("Fetching results for upcoming events...") @@ -81,7 +52,6 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } for _, job := range schedule { - job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 860d128..da85139 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -116,17 +116,20 @@ func (h *Handler) RandomBet(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) } - res, err := h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) + var res domain.CreateBetRes + var err error + for i := 0; i < int(req.NumberOfBets); i++ { + res, err = h.betSvc.PlaceRandomBet(c.Context(), userID, req.BranchID, leagueID, sportID, firstStartTime, lastStartTime) - if err != nil { - h.logger.Error("Random Bet failed", "error", err) - switch err { - case bet.ErrNoEventsAvailable: - return fiber.NewError(fiber.StatusBadRequest, "No events found") + if err != nil { + h.logger.Error("Random Bet failed", "error", err) + switch err { + case bet.ErrNoEventsAvailable: + return fiber.NewError(fiber.StatusBadRequest, "No events found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } - return fiber.NewError(fiber.StatusInternalServerError, "Unable to create random bet") } - return response.WriteJSON(c, fiber.StatusOK, "Bet Created", res, nil) } @@ -177,8 +180,9 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { bet, err := h.betSvc.GetBetByID(c.Context(), id) if err != nil { + // TODO: handle all the errors types h.logger.Error("Failed to get bet by ID", "betID", id, "error", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bet") + return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve bet") } res := domain.ConvertBet(bet) diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index 4ec72e7..8f090ee 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -492,6 +493,64 @@ func (h *Handler) GetBranchOperations(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Branch Operations retrieved successfully", result, nil) } +// GetBranchCashiers godoc +// @Summary Gets branch cashiers +// @Description Gets branch cashiers +// @Tags branch +// @Accept json +// @Produce json +// @Param id path int true "Branch ID" +// @Success 200 {array} GetCashierRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /branch/{id}/cashier [get] +func (h *Handler) GetBranchCashiers(c *fiber.Ctx) error { + branchID := c.Params("id") + id, err := strconv.ParseInt(branchID, 10, 64) + if err != nil { + h.logger.Error("Invalid branch ID", "branchID", branchID, "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid branch ID", err, nil) + } + + cashiers, err := h.userSvc.GetCashiersByBranch(c.Context(), id) + + if err != nil { + h.logger.Error("Failed to get cashier by branch ID", "branchID", id, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve cashier", err, nil) + } + + var result []GetCashierRes = make([]GetCashierRes, 0, len(cashiers)) + + for _, cashier := range cashiers { + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), cashier.ID) + if err != nil { + if err == authentication.ErrRefreshTokenNotFound { + lastLogin = &cashier.CreatedAt + } else { + h.logger.Error("Failed to get user last login", "userID", cashier.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + } + result = append(result, GetCashierRes{ + ID: cashier.ID, + FirstName: cashier.FirstName, + LastName: cashier.LastName, + Email: cashier.Email, + PhoneNumber: cashier.PhoneNumber, + Role: cashier.Role, + EmailVerified: cashier.EmailVerified, + PhoneVerified: cashier.PhoneVerified, + CreatedAt: cashier.CreatedAt, + UpdatedAt: cashier.UpdatedAt, + SuspendedAt: cashier.SuspendedAt, + Suspended: cashier.Suspended, + LastLogin: *lastLogin, + }) + } + + return response.WriteJSON(c, fiber.StatusOK, "Branch Cashiers retrieved successfully", result, nil) +} + // GetBetByBranchID godoc // @Summary Gets bets by its branch id // @Description Gets bets by its branch id diff --git a/internal/web_server/handlers/company_handler.go b/internal/web_server/handlers/company_handler.go index 6e0f713..46b8a7d 100644 --- a/internal/web_server/handlers/company_handler.go +++ b/internal/web_server/handlers/company_handler.go @@ -25,12 +25,15 @@ type CompanyRes struct { } type GetCompanyRes struct { - ID int64 `json:"id" example:"1"` - Name string `json:"name" example:"CompanyName"` - AdminID int64 `json:"admin_id" example:"1"` - WalletID int64 `json:"wallet_id" example:"1"` - WalletBalance float32 `json:"balance" example:"1"` - IsActive bool `json:"is_active" example:"false"` + ID int64 `json:"id" example:"1"` + Name string `json:"name" example:"CompanyName"` + AdminID int64 `json:"admin_id" example:"1"` + WalletID int64 `json:"wallet_id" example:"1"` + WalletBalance float32 `json:"balance" example:"1"` + IsActive bool `json:"is_active" example:"false"` + AdminFirstName string `json:"admin_first_name" example:"John"` + AdminLastName string `json:"admin_last_name" example:"Doe"` + AdminPhoneNumber string `json:"admin_phone_number" example:"1234567890"` } func convertCompany(company domain.Company) CompanyRes { @@ -44,12 +47,15 @@ func convertCompany(company domain.Company) CompanyRes { func convertGetCompany(company domain.GetCompany) GetCompanyRes { return GetCompanyRes{ - ID: company.ID, - Name: company.Name, - AdminID: company.AdminID, - WalletID: company.WalletID, - WalletBalance: company.WalletBalance.Float32(), - IsActive: company.IsWalletActive, + ID: company.ID, + Name: company.Name, + AdminID: company.AdminID, + WalletID: company.WalletID, + WalletBalance: company.WalletBalance.Float32(), + IsActive: company.IsWalletActive, + AdminFirstName: company.AdminFirstName, + AdminLastName: company.AdminLastName, + AdminPhoneNumber: company.AdminPhoneNumber, } } diff --git a/internal/web_server/handlers/ticket_handler.go b/internal/web_server/handlers/ticket_handler.go index f91d0f2..5885ce0 100644 --- a/internal/web_server/handlers/ticket_handler.go +++ b/internal/web_server/handlers/ticket_handler.go @@ -182,7 +182,7 @@ func (h *Handler) GetTicketByID(c *fiber.Ctx) error { ticket, err := h.ticketSvc.GetTicketByID(c.Context(), id) if err != nil { - // h.logger.Error("Failed to get ticket by ID", "ticketID", id, "error", err) + h.logger.Error("Failed to get ticket by ID", "ticketID", id, "error", err) return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve ticket") } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 09fd436..55de2af 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -450,7 +450,7 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error { // @Accept json // @Produce json // @Param id path int true "User ID" -// @Success 200 {object} response.APIResponse +// @Success 200 {object} UserProfileRes // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse @@ -513,3 +513,75 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", res, nil) } + +// DeleteUser godoc +// @Summary Delete user by ID +// @Description Delete a user by their ID +// @Tags user +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/delete/{id} [delete] +func (h *Handler) DeleteUser(c *fiber.Ctx) error { + userIDstr := c.Params("id") + userID, err := strconv.ParseInt(userIDstr, 10, 64) + if err != nil { + h.logger.Error("DeleteUser failed", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid user ID", nil, nil) + } + + err = h.userSvc.DeleteUser(c.Context(), userID) + if err != nil { + h.logger.Error("Failed to delete user", "userID", userID, "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to delete user", nil, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "User deleted successfully", nil, nil) +} + +type UpdateUserSuspendReq struct { + UserID int64 `json:"user_id" validate:"required" example:"123"` + Suspended bool `json:"suspended" validate:"required" example:"true"` +} +type UpdateUserSuspendRes struct { + UserID int64 `json:"user_id"` + Suspended bool `json:"suspended"` +} + +// UpdateUserSuspend godoc +// @Summary Suspend or unsuspend a user +// @Description Suspend or unsuspend a user +// @Tags user +// @Accept json +// @Produce json +// @Param updateUserSuspend body UpdateUserSuspendReq true "Suspend or unsuspend a user" +// @Success 200 {object} UpdateUserSuspendRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /user/suspend [post] +func (h *Handler) UpdateUserSuspend(c *fiber.Ctx) error { + var req UpdateUserSuspendReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("Failed to parse UpdateUserSuspend request", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + err := h.userSvc.UpdateUserSuspend(c.Context(), req.UserID, req.Suspended) + if err != nil { + h.logger.Error("Failed to update user suspend status", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update user suspend status") + } + + res := UpdateUserSuspendRes{ + UserID: req.UserID, + Suspended: req.Suspended, + } + return response.WriteJSON(c, fiber.StatusOK, "User suspend status updated successfully", res, nil) +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 0623d62..0ee2a83 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -35,7 +35,7 @@ func (a *App) initAppRoutes() { a.fiber.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "Welcome to the FortuneBet API", - "version": "1.0.1", + "version": "1.0dev2", }) }) @@ -77,6 +77,8 @@ func (a *App) initAppRoutes() { a.fiber.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) a.fiber.Get("/user/profile", a.authMiddleware, h.UserProfile) a.fiber.Get("/user/single/:id", a.authMiddleware, h.GetUserByID) + a.fiber.Delete("/user/delete/:id", a.authMiddleware, h.DeleteUser) + a.fiber.Post("/user/suspend", a.authMiddleware, h.UpdateUserSuspend) a.fiber.Get("/user/wallet", a.authMiddleware, h.GetCustomerWallet) a.fiber.Post("/user/search", a.authMiddleware, h.SearchUserByNameOrPhone) @@ -120,12 +122,14 @@ func (a *App) initAppRoutes() { a.fiber.Get("/search/branch", a.authMiddleware, h.SearchBranch) // /branch/search // branch/wallet + a.fiber.Get("/branch/:id/cashiers", a.authMiddleware, h.GetBranchCashiers) // Branch Operation a.fiber.Get("/supportedOperation", a.authMiddleware, h.GetAllSupportedOperations) a.fiber.Post("/supportedOperation", a.authMiddleware, h.CreateSupportedOperation) a.fiber.Post("/operation", a.authMiddleware, h.CreateBranchOperation) a.fiber.Get("/branch/:id/operation", a.authMiddleware, h.GetBranchOperations) + a.fiber.Delete("/branch/:id/operation/:opID", a.authMiddleware, h.DeleteBranchOperation) // Company @@ -145,13 +149,13 @@ func (a *App) initAppRoutes() { // Bet Routes a.fiber.Post("/bet", a.authMiddleware, h.CreateBet) a.fiber.Get("/bet", a.authMiddleware, h.GetAllBet) - a.fiber.Get("/bet/:id", a.authMiddleware, h.GetBetByID) + a.fiber.Get("/bet/:id", h.GetBetByID) a.fiber.Get("/bet/cashout/:id", a.authMiddleware, h.GetBetByCashoutID) a.fiber.Patch("/bet/:id", a.authMiddleware, h.UpdateCashOut) a.fiber.Delete("/bet/:id", a.authMiddleware, h.DeleteBet) - + a.fiber.Post("/random/bet", a.authMiddleware, h.RandomBet) - + // Wallet a.fiber.Get("/wallet", h.GetAllWallets) a.fiber.Get("/wallet/:id", h.GetWalletByID) From 6cc37fbf9fc37b32397b056984eb8dba1c6216e6 Mon Sep 17 00:00:00 2001 From: KidusAlemayehu Date: Sun, 18 May 2025 19:18:24 +0300 Subject: [PATCH 09/15] feat: Implement sports evaluation logic for NFL, Rugby, and Baseball - Added evaluation functions for NFL money line, spread, and total points. - Implemented evaluation functions for Rugby money line, spread, and total points. - Created evaluation functions for Baseball money line, spread, total runs, first inning, and first 5 innings. - Developed unit tests for all sports markets to ensure correct evaluation outcomes. - Introduced ResultCheckerService to handle game result checking for NFL, Rugby, and Baseball. - Updated routes to include new functionality for virtual game handling. --- go.mod | 13 +- go.sum | 14 +- internal/domain/sportmarket.go | 52 ++++ internal/domain/sports_result.go | 290 ++++++++++++++++++ internal/services/notfication/service.go | 1 + internal/services/result/eval.go | 31 ++ internal/services/result/service.go | 159 +++++++++- internal/services/result/sports_eval.go | 280 +++++++++++++++++ internal/services/result/sports_eval_test.go | 303 +++++++++++++++++++ internal/services/result_checker.go | 189 ++++++++++++ internal/web_server/routes.go | 1 + 11 files changed, 1302 insertions(+), 31 deletions(-) create mode 100644 internal/domain/sports_result.go create mode 100644 internal/services/result/sports_eval.go create mode 100644 internal/services/result/sports_eval_test.go create mode 100644 internal/services/result_checker.go diff --git a/go.mod b/go.mod index a510af6..308822c 100644 --- a/go.mod +++ b/go.mod @@ -7,23 +7,27 @@ require ( github.com/bytedance/sonic v1.13.2 github.com/go-playground/validator/v10 v10.26.0 github.com/gofiber/fiber/v2 v2.52.6 - github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 github.com/robfig/cron/v3 v3.0.1 + github.com/stretchr/testify v1.10.0 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 golang.org/x/crypto v0.36.0 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/fasthttp/websocket v1.5.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect @@ -31,7 +35,7 @@ require ( github.com/go-openapi/swag v0.23.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gofiber/contrib/websocket v1.3.4 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -45,11 +49,10 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.59.0 // indirect + github.com/valyala/fasthttp v1.59.0 golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect diff --git a/go.sum b/go.sum index 9e77972..ab1ac26 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= -github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= -github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= -github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -51,18 +47,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= -github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs= github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= -github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -118,10 +112,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= -github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= -github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= diff --git a/internal/domain/sportmarket.go b/internal/domain/sportmarket.go index b6fde09..4002f88 100644 --- a/internal/domain/sportmarket.go +++ b/internal/domain/sportmarket.go @@ -91,6 +91,38 @@ const ( ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY IceHockeyMarket = 170240 ) +type AmericanFootballMarket int64 + +const ( + // Main + AMERICAN_FOOTBALL_MONEY_LINE AmericanFootballMarket = 170001 + AMERICAN_FOOTBALL_SPREAD AmericanFootballMarket = 170002 + AMERICAN_FOOTBALL_TOTAL_POINTS AmericanFootballMarket = 170003 +) + +type RugbyMarket int64 + +const ( + // Main + RUGBY_MONEY_LINE RugbyMarket = 180001 + RUGBY_SPREAD RugbyMarket = 180002 + RUGBY_TOTAL_POINTS RugbyMarket = 180003 + RUGBY_HANDICAP RugbyMarket = 180004 + RUGBY_FIRST_HALF RugbyMarket = 180005 + RUGBY_SECOND_HALF RugbyMarket = 180006 +) + +type BaseballMarket int64 + +const ( + // Main + BASEBALL_MONEY_LINE BaseballMarket = 190001 + BASEBALL_SPREAD BaseballMarket = 190002 + BASEBALL_TOTAL_RUNS BaseballMarket = 190003 + BASEBALL_FIRST_INNING BaseballMarket = 190004 + BASEBALL_FIRST_5_INNINGS BaseballMarket = 190005 +) + // TODO: Move this into the database so that it can be modified dynamically var SupportedMarkets = map[int64]bool{ @@ -164,4 +196,24 @@ var SupportedMarkets = map[int64]bool{ int64(ICE_HOCKEY_ALTERNATIVE_PUCK_LINE_TWO_WAY): false, int64(ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY): false, + + // American Football Markets + int64(AMERICAN_FOOTBALL_MONEY_LINE): true, + int64(AMERICAN_FOOTBALL_SPREAD): true, + int64(AMERICAN_FOOTBALL_TOTAL_POINTS): true, + + // Rugby Markets + int64(RUGBY_MONEY_LINE): true, + int64(RUGBY_SPREAD): true, + int64(RUGBY_TOTAL_POINTS): true, + int64(RUGBY_HANDICAP): true, + int64(RUGBY_FIRST_HALF): true, + int64(RUGBY_SECOND_HALF): true, + + // Baseball Markets + int64(BASEBALL_MONEY_LINE): true, + int64(BASEBALL_SPREAD): true, + int64(BASEBALL_TOTAL_RUNS): true, + int64(BASEBALL_FIRST_INNING): true, + int64(BASEBALL_FIRST_5_INNINGS): true, } diff --git a/internal/domain/sports_result.go b/internal/domain/sports_result.go new file mode 100644 index 0000000..448c4de --- /dev/null +++ b/internal/domain/sports_result.go @@ -0,0 +1,290 @@ +package domain + +import ( + "encoding/json" + "strconv" + "strings" +) + +// NFLResultResponse represents the structure for NFL game results +type NFLResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + ThirdQuarter Score `json:"3"` + FourthQuarter Score `json:"4"` + Overtime Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + FirstDowns []string `json:"first_downs"` + TotalYards []string `json:"total_yards"` + PassingYards []string `json:"passing_yards"` + RushingYards []string `json:"rushing_yards"` + Turnovers []string `json:"turnovers"` + TimeOfPossession []string `json:"time_of_possession"` + ThirdDownEfficiency []string `json:"third_down_efficiency"` + FourthDownEfficiency []string `json:"fourth_down_efficiency"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// RugbyResultResponse represents the structure for Rugby game results +type RugbyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Tries []string `json:"tries"` + Conversions []string `json:"conversions"` + Penalties []string `json:"penalties"` + DropGoals []string `json:"drop_goals"` + Possession []string `json:"possession"` + Territory []string `json:"territory"` + Lineouts []string `json:"lineouts"` + Scrums []string `json:"scrums"` + PenaltiesConceded []string `json:"penalties_conceded"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// BaseballResultResponse represents the structure for Baseball game results +type BaseballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` + } `json:"league"` + Home struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"home"` + Away struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` + } `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstInning Score `json:"1"` + SecondInning Score `json:"2"` + ThirdInning Score `json:"3"` + FourthInning Score `json:"4"` + FifthInning Score `json:"5"` + SixthInning Score `json:"6"` + SeventhInning Score `json:"7"` + EighthInning Score `json:"8"` + NinthInning Score `json:"9"` + ExtraInnings Score `json:"10"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + Hits []string `json:"hits"` + Errors []string `json:"errors"` + LeftOnBase []string `json:"left_on_base"` + Strikeouts []string `json:"strikeouts"` + Walks []string `json:"walks"` + HomeRuns []string `json:"home_runs"` + TotalBases []string `json:"total_bases"` + BattingAverage []string `json:"batting_average"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +// ParseNFLResult parses NFL result from raw JSON data +func ParseNFLResult(data json.RawMessage) (*NFLResultResponse, error) { + var result NFLResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyResult parses Rugby result from raw JSON data +func ParseRugbyResult(data json.RawMessage) (*RugbyResultResponse, error) { + var result RugbyResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ParseRugbyUnionResult parses Rugby Union result from raw JSON data +func ParseRugbyUnionResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseRugbyLeagueResult parses Rugby League result from raw JSON data +func ParseRugbyLeagueResult(data json.RawMessage) (*RugbyResultResponse, error) { + return ParseRugbyResult(data) +} + +// ParseBaseballResult parses Baseball result from raw JSON data +func ParseBaseballResult(data json.RawMessage) (*BaseballResultResponse, error) { + var result BaseballResultResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return &result, nil +} + +// GetNFLWinner determines the winner of an NFL game +func GetNFLWinner(result *NFLResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetRugbyWinner determines the winner of a Rugby game +func GetRugbyWinner(result *RugbyResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// GetBaseballWinner determines the winner of a Baseball game +func GetBaseballWinner(result *BaseballResultResponse) (string, error) { + homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home) + if err != nil { + return "", err + } + awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away) + if err != nil { + return "", err + } + + if homeScore > awayScore { + return result.Home.Name, nil + } else if awayScore > homeScore { + return result.Away.Name, nil + } + return "Draw", nil +} + +// FormatNFLScore formats the NFL score in a readable format +func FormatNFLScore(result *NFLResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatRugbyScore formats the Rugby score in a readable format +func FormatRugbyScore(result *RugbyResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} + +// FormatBaseballScore formats the Baseball score in a readable format +func FormatBaseballScore(result *BaseballResultResponse) string { + return strings.Join([]string{ + result.Home.Name + " " + result.Scores.TotalScore.Home, + result.Away.Name + " " + result.Scores.TotalScore.Away, + }, " - ") +} diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index 9c5597e..368e637 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -266,3 +266,4 @@ func (s *Service) retryFailedNotifications() { }(notification) } } + diff --git a/internal/services/result/eval.go b/internal/services/result/eval.go index 8096e3a..1abce5d 100644 --- a/internal/services/result/eval.go +++ b/internal/services/result/eval.go @@ -709,3 +709,34 @@ func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Hom return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid oddname: %s", outcome.OddName) } +// evaluateRugbyOutcome evaluates the outcome of a Rugby bet +func evaluateRugbyOutcome(outcome domain.BetOutcome, result *domain.RugbyResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateRugbyMoneyLine(outcome, finalScore) + case "Spread": + return evaluateRugbySpread(outcome, finalScore) + case "Total Points": + return evaluateRugbyTotalPoints(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported rugby market: %s", outcome.MarketName) + } +} + +// evaluateBaseballOutcome evaluates the outcome of a Baseball bet +func evaluateBaseballOutcome(outcome domain.BetOutcome, result *domain.BaseballResultResponse) (domain.OutcomeStatus, error) { + finalScore := parseSS(result.SS) + + switch outcome.MarketName { + case "Money Line": + return evaluateBaseballMoneyLine(outcome, finalScore) + case "Spread": + return evaluateBaseballSpread(outcome, finalScore) + case "Total Runs": + return evaluateBaseballTotalRuns(outcome, finalScore) + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported baseball market: %s", outcome.MarketName) + } +} diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 74983cb..b0cef8e 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -202,30 +202,24 @@ func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, spo switch sportID { case domain.FOOTBALL: result, err = s.parseFootball(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse football", "event_id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } - case domain.BASKETBALL: result, err = s.parseBasketball(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse basketball", "event_id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } case domain.ICE_HOCKEY: result, err = s.parseIceHockey(resultResp.Results[0], eventID, oddID, marketID, outcome) - if err != nil { - s.logger.Error("Failed to parse ice hockey", "event id", eventID, "market_id", marketID, "error", err) - return domain.CreateResult{}, err - } + case domain.AMERICAN_FOOTBALL: + result, err = s.parseNFL(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_UNION: + result, err = s.parseRugbyUnion(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.RUGBY_LEAGUE: + result, err = s.parseRugbyLeague(resultResp.Results[0], eventID, oddID, marketID, outcome) + case domain.BASEBALL: + result, err = s.parseBaseball(resultResp.Results[0], eventID, oddID, marketID, outcome) default: s.logger.Error("Unsupported sport", "sport", sportID) return domain.CreateResult{}, fmt.Errorf("unsupported sport: %v", sportID) } return result, nil - } func (s *Service) parseFootball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { @@ -317,6 +311,124 @@ func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marke } +func (s *Service) parseNFL(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var nflResp domain.NFLResultResponse + if err := json.Unmarshal(resultRes, &nflResp); err != nil { + s.logger.Error("Failed to unmarshal NFL result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + + if nflResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + + finalScore := parseSS(nflResp.SS) + + var status domain.OutcomeStatus + var err error + + switch outcome.MarketName { + case "Money Line": + status, err = evaluateNFLMoneyLine(outcome, finalScore) + case "Spread": + status, err = evaluateNFLSpread(outcome, finalScore) + case "Total Points": + status, err = evaluateNFLTotalPoints(outcome, finalScore) + default: + return domain.CreateResult{}, fmt.Errorf("unsupported market: %s", outcome.MarketName) + } + + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: nflResp.SS, + }, nil +} + +func (s *Service) parseRugbyUnion(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby Union result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseRugbyLeague(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var rugbyResp domain.RugbyResultResponse + if err := json.Unmarshal(resultRes, &rugbyResp); err != nil { + s.logger.Error("Failed to unmarshal Rugby League result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if rugbyResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateRugbyOutcome(outcome, &rugbyResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: rugbyResp.SS, + }, nil +} + +func (s *Service) parseBaseball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) { + var baseballResp domain.BaseballResultResponse + if err := json.Unmarshal(resultRes, &baseballResp); err != nil { + s.logger.Error("Failed to unmarshal Baseball result", "event_id", eventID, "error", err) + return domain.CreateResult{}, err + } + if baseballResp.TimeStatus != "3" { + s.logger.Warn("Match not yet completed", "event_id", eventID) + return domain.CreateResult{}, fmt.Errorf("match not yet completed") + } + status, err := evaluateBaseballOutcome(outcome, &baseballResp) + if err != nil { + s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err) + return domain.CreateResult{}, err + } + return domain.CreateResult{ + BetOutcomeID: outcome.ID, + EventID: eventID, + OddID: oddID, + MarketID: marketID, + Status: status, + Score: baseballResp.SS, + }, nil +} + func parseScore(home string, away string) struct{ Home, Away int } { homeVal, _ := strconv.Atoi(strings.TrimSpace(home)) awaVal, _ := strconv.Atoi(strings.TrimSpace(away)) @@ -487,3 +599,22 @@ func (s *Service) evaluateIceHockeyOutcome(outcome domain.BetOutcome, res domain return domain.OUTCOME_STATUS_PENDING, nil } + +func (s *Service) evaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) { + if !domain.SupportedMarkets[outcome.MarketID] { + s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName) + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName) + } + + switch outcome.MarketID { + case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE): + return evaluateNFLMoneyLine(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + return evaluateNFLSpread(outcome, finalScore) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + return evaluateNFLTotalPoints(outcome, finalScore) + default: + s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName) + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName) + } +} diff --git a/internal/services/result/sports_eval.go b/internal/services/result/sports_eval.go new file mode 100644 index 0000000..eeb23f7 --- /dev/null +++ b/internal/services/result/sports_eval.go @@ -0,0 +1,280 @@ +package result + +import ( + "fmt" + "strconv" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// NFL evaluations +func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +func evaluateNFLSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +func evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateRugbyMoneyLine evaluates Rugby money line bets +func evaluateRugbyMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateRugbySpread evaluates Rugby spread bets +func evaluateRugbySpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +// evaluateRugbyTotalPoints evaluates Rugby total points bets +func evaluateRugbyTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalPoints := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalPoints > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalPoints < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalPoints == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateBaseballMoneyLine evaluates Baseball money line bets +func evaluateBaseballMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballSpread evaluates Baseball spread bets +func evaluateBaseballSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap) + } + + adjustedHomeScore := float64(score.Home) + adjustedAwayScore := float64(score.Away) + + if outcome.OddHeader == "1" { + adjustedHomeScore += handicap + } else if outcome.OddHeader == "2" { + adjustedAwayScore += handicap + } else { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } + + if adjustedHomeScore > adjustedAwayScore { + if outcome.OddHeader == "1" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if adjustedHomeScore < adjustedAwayScore { + if outcome.OddHeader == "2" { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_VOID, nil +} + +// evaluateBaseballTotalRuns evaluates Baseball total runs bets +func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + totalRuns := float64(score.Home + score.Away) + threshold, err := strconv.ParseFloat(outcome.OddName, 64) + if err != nil { + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName) + } + + if outcome.OddHeader == "Over" { + if totalRuns > threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } else if outcome.OddHeader == "Under" { + if totalRuns < threshold { + return domain.OUTCOME_STATUS_WIN, nil + } else if totalRuns == threshold { + return domain.OUTCOME_STATUS_VOID, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + } + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) +} + +// evaluateBaseballFirstInning evaluates Baseball first inning bets +func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} + +// evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets +func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) { + switch outcome.OddHeader { + case "1": + if score.Home > score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "2": + if score.Away > score.Home { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + case "X": + if score.Home == score.Away { + return domain.OUTCOME_STATUS_WIN, nil + } + return domain.OUTCOME_STATUS_LOSS, nil + default: + return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader) + } +} diff --git a/internal/services/result/sports_eval_test.go b/internal/services/result/sports_eval_test.go new file mode 100644 index 0000000..f300879 --- /dev/null +++ b/internal/services/result/sports_eval_test.go @@ -0,0 +1,303 @@ +package result + +import ( + "testing" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/stretchr/testify/assert" +) + +// TestNFLMarkets covers all American Football (NFL) market types defined in the domain. +// For each market (Money Line, Spread, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestNFLMarkets(t *testing.T) { + t.Log("Testing NFL (American Football) Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.AMERICAN_FOOTBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.AMERICAN_FOOTBALL_SPREAD), "SPREAD"}, + {int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS), "TOTAL_POINTS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given NFL market. + switch m.marketID { + case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 14, Away: 21}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 17, Away: 17}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-3.5"}, struct{ Home, Away int }{Home: 24, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+3.5"}, struct{ Home, Away int }{Home: 20, Away: 24}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "44.5"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "44.5"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "37"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 17}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestRugbyMarkets covers all Rugby (Union & League) market types defined in the domain. +// For each market (Money Line, Spread, Handicap, Total Points), it tests home/away win, draw, void, and invalid input scenarios. +func TestRugbyMarkets(t *testing.T) { + t.Log("Testing Rugby Markets (Union & League)") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.RUGBY_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.RUGBY_SPREAD), "SPREAD"}, + {int64(domain.RUGBY_TOTAL_POINTS), "TOTAL_POINTS"}, + {int64(domain.RUGBY_HANDICAP), "HANDICAP"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Rugby market. + switch m.marketID { + case int64(domain.RUGBY_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 30, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 20, Away: 30}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 25, Away: 25}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_SPREAD), int64(domain.RUGBY_HANDICAP): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-6.5"}, struct{ Home, Away int }{Home: 28, Away: 20}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+6.5"}, struct{ Home, Away int }{Home: 20, Away: 28}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.RUGBY_TOTAL_POINTS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "40.5"}, struct{ Home, Away int }{Home: 25, Away: 20}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "40.5"}, struct{ Home, Away int }{Home: 15, Away: 20}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "35"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 15}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} + +// TestBaseballMarkets covers all Baseball market types defined in the domain. +// For each market (Money Line, Spread, Total Runs), it tests home/away win, draw, void, and invalid input scenarios. +func TestBaseballMarkets(t *testing.T) { + t.Log("Testing Baseball Markets") + markets := []struct { + marketID int64 + name string + }{ + {int64(domain.BASEBALL_MONEY_LINE), "MONEY_LINE"}, + {int64(domain.BASEBALL_SPREAD), "SPREAD"}, + {int64(domain.BASEBALL_TOTAL_RUNS), "TOTAL_RUNS"}, + } + + for _, m := range markets { + t.Run(m.name, func(t *testing.T) { + // Each subtest below covers a key scenario for the given Baseball market. + switch m.marketID { + case int64(domain.BASEBALL_MONEY_LINE): + // Home win, away win, draw, and invalid OddHeader for Money Line + t.Run("Home Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 6, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 2, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Draw", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Draw", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status) + }) + t.Run("Invalid OddHeader", func(t *testing.T) { + status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7}) + t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_SPREAD): + t.Run("Home Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-1.5"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Away Win with Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+1.5"}, struct{ Home, Away int }{Home: 3, Away: 5}) + t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 4, Away: 4}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric Handicap", func(t *testing.T) { + status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 5, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + case int64(domain.BASEBALL_TOTAL_RUNS): + t.Run("Over Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7.5"}, struct{ Home, Away int }{Home: 5, Away: 4}) + t.Logf("Market: %s, Scenario: Over Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Under Win", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "7.5"}, struct{ Home, Away int }{Home: 2, Away: 3}) + t.Logf("Market: %s, Scenario: Under Win", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_WIN, status) + }) + t.Run("Push (Void)", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Push (Void)", m.name) + assert.NoError(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_VOID, status) + }) + t.Run("Non-numeric OddName", func(t *testing.T) { + status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 4, Away: 3}) + t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name) + assert.Error(t, err) + assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status) + }) + } + }) + } +} diff --git a/internal/services/result_checker.go b/internal/services/result_checker.go new file mode 100644 index 0000000..59d4b1c --- /dev/null +++ b/internal/services/result_checker.go @@ -0,0 +1,189 @@ +package services + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +// ResultCheckerService handles the checking of game results +type ResultCheckerService struct { + // Add any dependencies here (e.g., repositories, external APIs) +} + +// NewResultCheckerService creates a new instance of ResultCheckerService +func NewResultCheckerService() *ResultCheckerService { + return &ResultCheckerService{} +} + +// CheckNFLResult checks the result of an NFL game +func (s *ResultCheckerService) CheckNFLResult(data json.RawMessage) (*domain.Result, error) { + nflResult, err := domain.ParseNFLResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse NFL result: %w", err) + } + + winner, err := domain.GetNFLWinner(nflResult) + if err != nil { + return nil, fmt.Errorf("failed to determine NFL winner: %w", err) + } + + score := domain.FormatNFLScore(nflResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, nflResult.Home.Name, nflResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: nflResult.SS, + Scores: map[string]domain.Score{ + "1": nflResult.Scores.FirstQuarter, + "2": nflResult.Scores.SecondQuarter, + "3": nflResult.Scores.ThirdQuarter, + "4": nflResult.Scores.FourthQuarter, + "5": nflResult.Scores.Overtime, + "7": nflResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// determineOutcomeStatus determines the outcome status based on the winner and teams +func determineOutcomeStatus(winner, homeTeam, awayTeam string) domain.OutcomeStatus { + if winner == "Draw" { + return domain.OUTCOME_STATUS_VOID + } + if winner == homeTeam { + return domain.OUTCOME_STATUS_WIN + } + if winner == awayTeam { + return domain.OUTCOME_STATUS_LOSS + } + return domain.OUTCOME_STATUS_PENDING +} + +// CheckRugbyResult checks the result of a Rugby game +func (s *ResultCheckerService) CheckRugbyResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckBaseballResult checks the result of a Baseball game +func (s *ResultCheckerService) CheckBaseballResult(data json.RawMessage) (*domain.Result, error) { + baseballResult, err := domain.ParseBaseballResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Baseball result: %w", err) + } + + winner, err := domain.GetBaseballWinner(baseballResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Baseball winner: %w", err) + } + + score := domain.FormatBaseballScore(baseballResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, baseballResult.Home.Name, baseballResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: baseballResult.SS, + Scores: map[string]domain.Score{ + "1": baseballResult.Scores.FirstInning, + "2": baseballResult.Scores.SecondInning, + "3": baseballResult.Scores.ThirdInning, + "4": baseballResult.Scores.FourthInning, + "5": baseballResult.Scores.FifthInning, + "6": baseballResult.Scores.SixthInning, + "7": baseballResult.Scores.SeventhInning, + "8": baseballResult.Scores.EighthInning, + "9": baseballResult.Scores.NinthInning, + "10": baseballResult.Scores.ExtraInnings, + "T": baseballResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyUnionResult checks the result of a Rugby Union game +func (s *ResultCheckerService) CheckRugbyUnionResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyUnionResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby Union result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby Union winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} + +// CheckRugbyLeagueResult checks the result of a Rugby League game +func (s *ResultCheckerService) CheckRugbyLeagueResult(data json.RawMessage) (*domain.Result, error) { + rugbyResult, err := domain.ParseRugbyLeagueResult(data) + if err != nil { + return nil, fmt.Errorf("failed to parse Rugby League result: %w", err) + } + + winner, err := domain.GetRugbyWinner(rugbyResult) + if err != nil { + return nil, fmt.Errorf("failed to determine Rugby League winner: %w", err) + } + + score := domain.FormatRugbyScore(rugbyResult) + + return &domain.Result{ + Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name), + Score: score, + FullTimeScore: score, + SS: rugbyResult.SS, + Scores: map[string]domain.Score{ + "1": rugbyResult.Scores.FirstHalf, + "2": rugbyResult.Scores.SecondHalf, + "7": rugbyResult.Scores.TotalScore, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 70fb79a..77da6f6 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -177,6 +177,7 @@ func (a *App) initAppRoutes() { // Virtual Game Routes a.fiber.Post("/virtual-game/launch", a.authMiddleware, h.LaunchVirtualGame) a.fiber.Post("/virtual-game/callback", h.HandleVirtualGameCallback) + } ///user/profile get From cdc40397b95a430875c531e37ec30cdc06da878c Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Mon, 19 May 2025 15:38:52 +0300 Subject: [PATCH 10/15] fix: date filter --- db/query/events.sql | 4 +--- docs/docs.go | 12 ++++++++++++ docs/swagger.json | 12 ++++++++++++ docs/swagger.yaml | 8 ++++++++ gen/db/events.sql.go | 4 +--- internal/repository/event.go | 2 +- internal/services/event/service.go | 6 ++---- internal/web_server/cron.go | 2 +- internal/web_server/handlers/prematch.go | 3 +++ 9 files changed, 41 insertions(+), 12 deletions(-) diff --git a/db/query/events.sql b/db/query/events.sql index 1e40107..652ac5d 100644 --- a/db/query/events.sql +++ b/db/query/events.sql @@ -158,9 +158,7 @@ SELECT id, status, fetched_at FROM events -WHERE is_live = false - AND status = 'upcoming' - AND start_time < now() +WHERE start_time < now() ORDER BY start_time ASC; -- name: GetTotalEvents :one SELECT COUNT(*) diff --git a/docs/docs.go b/docs/docs.go index b817783..c3a8ea7 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1705,6 +1705,18 @@ const docTemplate = `{ "description": "Sport ID Filter", "name": "sport_id", "in": "query" + }, + { + "type": "string", + "description": "Start Time", + "name": "first_start_time", + "in": "query" + }, + { + "type": "string", + "description": "End Time", + "name": "last_start_time", + "in": "query" } ], "responses": { diff --git a/docs/swagger.json b/docs/swagger.json index d4e8cfe..272d16b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1697,6 +1697,18 @@ "description": "Sport ID Filter", "name": "sport_id", "in": "query" + }, + { + "type": "string", + "description": "Start Time", + "name": "first_start_time", + "in": "query" + }, + { + "type": "string", + "description": "End Time", + "name": "last_start_time", + "in": "query" } ], "responses": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0387494..3dd0f4b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2306,6 +2306,14 @@ paths: in: query name: sport_id type: string + - description: Start Time + in: query + name: first_start_time + type: string + - description: End Time + in: query + name: last_start_time + type: string produces: - application/json responses: diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 6c1e083..3879adf 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -118,9 +118,7 @@ SELECT id, status, fetched_at FROM events -WHERE is_live = false - AND status = 'upcoming' - AND start_time < now() +WHERE start_time < now() ORDER BY start_time ASC ` diff --git a/internal/repository/event.go b/internal/repository/event.go index 904ca2c..03b3abb 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -133,7 +133,7 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val Valid: limit.Valid, }, Offset: pgtype.Int4{ - Int32: int32(offset.Value), + Int32: int32(offset.Value * limit.Value), Valid: offset.Valid, }, FirstStartTime: pgtype.Timestamp{ diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 37781d1..fe51aa7 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -99,8 +99,7 @@ func (s *service) FetchLiveEvents(ctx context.Context) error { } func (s *service) FetchUpcomingEvents(ctx context.Context) error { - // sportIDs := []int{1, 18, 17} - sportIDs := []int{18, 17} + sportIDs := []int{1, 18, 17} for _, sportID := range sportIDs { var totalPages int = 1 @@ -142,7 +141,6 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { ID string `json:"id"` Name string `json:"name"` } `json:"away"` - } `json:"results"` } if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { @@ -165,7 +163,7 @@ func (s *service) FetchUpcomingEvents(ctx context.Context) error { } if !slices.Contains(domain.SupportedLeagues, leagueID) { - fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) + // fmt.Printf("⚠️ Skipping league %s (%d) as it is not supported\n", ev.League.Name, leagueID) skippedLeague = append(skippedLeague, ev.League.Name) continue } diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 1bbefe7..769d0b3 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -52,6 +52,7 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } for _, job := range schedule { + // job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } @@ -82,7 +83,6 @@ func StartTicketCrons(ticketService ticket.Service) { } for _, job := range schedule { - job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go index 52e3780..b8d3778 100644 --- a/internal/web_server/handlers/prematch.go +++ b/internal/web_server/handlers/prematch.go @@ -99,6 +99,8 @@ func (h *Handler) GetRawOddsByMarketID(c *fiber.Ctx) error { // @Param page_size query int false "Page size" // @Param league_id query string false "League ID Filter" // @Param sport_id query string false "Sport ID Filter" +// @Param first_start_time query string false "Start Time" +// @Param last_start_time query string false "End Time" // @Success 200 {array} domain.UpcomingEvent // @Failure 500 {object} response.APIResponse // @Router /prematch/events [get] @@ -152,6 +154,7 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { Value: int64(page - 1), Valid: true, } + events, total, err := h.eventSvc.GetPaginatedUpcomingEvents( c.Context(), limit, offset, leagueID, sportID, firstStartTime, lastStartTime) From e838fea0202fbdf26ae45f7604e156e9cbee5643 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Tue, 20 May 2025 05:21:04 +0300 Subject: [PATCH 11/15] fix: get total --- db/query/events.sql | 16 ++++++--- gen/db/events.sql.go | 22 ++++++++++-- internal/repository/event.go | 9 ++++- internal/services/result/service.go | 22 ++++++++---- internal/web_server/cron.go | 34 +++++++++---------- .../web_server/handlers/ticket_handler.go | 9 ++--- 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/db/query/events.sql b/db/query/events.sql index 652ac5d..5fb5e46 100644 --- a/db/query/events.sql +++ b/db/query/events.sql @@ -166,12 +166,20 @@ FROM events WHERE is_live = false AND status = 'upcoming' AND ( - league_id = $1 - OR $1 IS NULL + league_id = sqlc.narg('league_id') + OR sqlc.narg('league_id') IS NULL ) AND ( - sport_id = $2 - OR $2 IS NULL + sport_id = sqlc.narg('sport_id') + OR sqlc.narg('sport_id') IS NULL + ) + AND ( + start_time < sqlc.narg('last_start_time') + OR sqlc.narg('last_start_time') IS NULL + ) + AND ( + start_time > sqlc.narg('first_start_time') + OR sqlc.narg('first_start_time') IS NULL ); -- name: GetPaginatedUpcomingEvents :many SELECT id, diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 3879adf..37bdad1 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -1,3 +1,4 @@ + // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.28.0 @@ -303,15 +304,30 @@ WHERE is_live = false sport_id = $2 OR $2 IS NULL ) + AND ( + start_time < $3 + OR $3 IS NULL + ) + AND ( + start_time > $4 + OR $4 IS NULL + ) ` type GetTotalEventsParams struct { - LeagueID pgtype.Text `json:"league_id"` - SportID pgtype.Text `json:"sport_id"` + LeagueID pgtype.Text `json:"league_id"` + SportID pgtype.Text `json:"sport_id"` + LastStartTime pgtype.Timestamp `json:"last_start_time"` + FirstStartTime pgtype.Timestamp `json:"first_start_time"` } func (q *Queries) GetTotalEvents(ctx context.Context, arg GetTotalEventsParams) (int64, error) { - row := q.db.QueryRow(ctx, GetTotalEvents, arg.LeagueID, arg.SportID) + row := q.db.QueryRow(ctx, GetTotalEvents, + arg.LeagueID, + arg.SportID, + arg.LastStartTime, + arg.FirstStartTime, + ) var count int64 err := row.Scan(&count) return count, err diff --git a/internal/repository/event.go b/internal/repository/event.go index 03b3abb..b65e034 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -149,7 +149,6 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val if err != nil { return nil, 0, err } - upcomingEvents := make([]domain.UpcomingEvent, len(events)) for i, e := range events { upcomingEvents[i] = domain.UpcomingEvent{ @@ -177,6 +176,14 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, limit domain.Val String: sportID.Value, Valid: sportID.Valid, }, + FirstStartTime: pgtype.Timestamp{ + Time: firstStartTime.Value.UTC(), + Valid: firstStartTime.Valid, + }, + LastStartTime: pgtype.Timestamp{ + Time: lastStartTime.Value.UTC(), + Valid: lastStartTime.Valid, + }, }) if err != nil { return nil, 0, err diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 77d3a2e..c5d7372 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -47,8 +47,9 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { return err } fmt.Printf("⚠️ Expired Events: %d \n", len(events)) + removed := 0 for i, event := range events { - fmt.Printf("🕛 Checking if event has bets placed on it %v (%d/%d) \n", event.ID, i+1, len(events)) + eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { s.logger.Error("Failed to parse event id") @@ -61,7 +62,9 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { } if len(outcomes) == 0 { - continue + fmt.Printf("🕛 No bets have been placed on event %v (%d/%d) \n", event.ID, i+1, len(events)) + } else { + fmt.Printf("✅ %d bets have been placed on event %v (%d/%d) \n", len(outcomes), event.ID, i+1, len(events)) } isDeleted := true @@ -126,12 +129,14 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { if err != bet.ErrOutcomesNotCompleted { s.logger.Error("Failed to check bet outcome for bet", "event_id", outcome.EventID, "error", err) } + isDeleted = false continue } fmt.Printf("🧾 Updating bet %v - event %v (%d/%d) to %v\n", outcome.BetID, event.ID, j+1, len(outcomes), status.String()) err = s.betSvc.UpdateStatus(ctx, outcome.BetID, status) if err != nil { s.logger.Error("Failed to update bet status", "event id", outcome.EventID, "error", err) + isDeleted = false continue } fmt.Printf("✅ Successfully updated 🎫 Bet %v - event %v(%v) (%d/%d) \n", @@ -141,14 +146,17 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { } if isDeleted { - // err = s.repo.DeleteEvent(ctx, event.ID) - // if err != nil { - // s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) - // return err - // } + removed += 1 + fmt.Printf("⚠️ Removing Event %v \n", event.ID) + err = s.repo.DeleteEvent(ctx, event.ID) + if err != nil { + s.logger.Error("Failed to remove event", "event_id", event.ID, "error", err) + return err + } } } + fmt.Printf("🗑️ Removed Events: %d \n", removed) return nil } diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 769d0b3..8e0bfdd 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -21,22 +21,22 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - { - spec: "0 0 * * * *", // Every 1 hour - task: func() { - if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - log.Printf("FetchUpcomingEvents error: %v", err) - } - }, - }, - { - spec: "0 */15 * * * *", // Every 15 minutes - task: func() { - if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - log.Printf("FetchNonLiveOdds error: %v", err) - } - }, - }, + // { + // spec: "0 0 * * * *", // Every 1 hour + // task: func() { + // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + // log.Printf("FetchUpcomingEvents error: %v", err) + // } + // }, + // }, + // { + // spec: "0 */15 * * * *", // Every 15 minutes + // task: func() { + // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + // log.Printf("FetchNonLiveOdds error: %v", err) + // } + // }, + // }, { spec: "0 */15 * * * *", // Every 15 Minutes task: func() { @@ -52,7 +52,7 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } for _, job := range schedule { - // job.task() + job.task() if _, err := c.AddFunc(job.spec, job.task); err != nil { log.Fatalf("Failed to schedule cron job: %v", err) } diff --git a/internal/web_server/handlers/ticket_handler.go b/internal/web_server/handlers/ticket_handler.go index 5885ce0..de3eeb2 100644 --- a/internal/web_server/handlers/ticket_handler.go +++ b/internal/web_server/handlers/ticket_handler.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "strconv" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" @@ -76,10 +77,10 @@ func (h *Handler) CreateTicket(c *fiber.Ctx) error { } // Checking to make sure the event hasn't already started - // currentTime := time.Now() - // if event.StartTime.Before(currentTime) { - // return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) - // } + currentTime := time.Now() + if event.StartTime.Before(currentTime) { + return response.WriteJSON(c, fiber.StatusBadRequest, "The event has already expired", nil, nil) + } odds, err := h.prematchSvc.GetRawOddsByMarketID(c.Context(), marketIDStr, eventIDStr) From 6a06b399c7d1c36b5a2599cae6caca5fd4a58d7d Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 22 May 2025 00:15:30 +0300 Subject: [PATCH 12/15] fix: notification integration issues --- db/query/notification.sql | 78 +++++++++-- db/query/user.sql | 3 +- gen/db/events.sql.go | 1 - gen/db/notification.sql.go | 124 ++++++++++++++++-- gen/db/user.sql.go | 8 +- internal/domain/common.go | 4 + internal/repository/notification.go | 24 ++++ internal/repository/user.go | 10 +- internal/services/notfication/port.go | 2 + internal/services/notfication/service.go | 56 +++++--- internal/services/user/direct.go | 4 +- internal/web_server/handlers/admin.go | 13 +- internal/web_server/handlers/manager.go | 12 +- .../handlers/notification_handler.go | 75 +++++++++-- internal/web_server/routes.go | 4 +- makefile | 4 +- 16 files changed, 352 insertions(+), 70 deletions(-) diff --git a/db/query/notification.sql b/db/query/notification.sql index 22bae8d..8a1c51f 100644 --- a/db/query/notification.sql +++ b/db/query/notification.sql @@ -1,21 +1,71 @@ -- name: CreateNotification :one INSERT INTO notifications ( - id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, timestamp, metadata -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 -) RETURNING *; - + id, + recipient_id, + type, + level, + error_severity, + reciever, + is_read, + delivery_status, + delivery_channel, + payload, + priority, + timestamp, + metadata + ) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13 + ) +RETURNING *; -- name: GetNotification :one -SELECT * FROM notifications WHERE id = $1 LIMIT 1; - +SELECT * +FROM notifications +WHERE id = $1 +LIMIT 1; +-- name: GetAllNotifications :many +SELECT * +FROM notifications +ORDER BY timestamp DESC +LIMIT $1 OFFSET $2; -- name: ListNotifications :many -SELECT * FROM notifications WHERE recipient_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3; - +SELECT * +FROM notifications +WHERE recipient_id = $1 +ORDER BY timestamp DESC +LIMIT $2 OFFSET $3; +-- name: CountUnreadNotifications :one +SELECT count(id) +FROM notifications +WHERE recipient_id = $1 + AND is_read = false; -- name: UpdateNotificationStatus :one -UPDATE notifications SET delivery_status = $2, is_read = $3, metadata = $4 WHERE id = $1 RETURNING *; - +UPDATE notifications +SET delivery_status = $2, + is_read = $3, + metadata = $4 +WHERE id = $1 +RETURNING *; -- name: ListFailedNotifications :many -SELECT * FROM notifications WHERE delivery_status = 'failed' AND timestamp < NOW() - INTERVAL '1 hour' ORDER BY timestamp ASC LIMIT $1; - +SELECT * +FROM notifications +WHERE delivery_status = 'failed' + AND timestamp < NOW() - INTERVAL '1 hour' +ORDER BY timestamp ASC +LIMIT $1; -- name: ListRecipientIDsByReceiver :many -SELECT recipient_id FROM notifications WHERE reciever = $1; +SELECT recipient_id +FROM notifications +WHERE reciever = $1; \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index c5799e8..84cfe4a 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -66,7 +66,7 @@ wHERE ( company_id = $2 OR $2 IS NULL ) -LIMIT $3 OFFSET $4; +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetTotalUsers :one SELECT COUNT(*) FROM users @@ -109,7 +109,6 @@ WHERE id = $7; UPDATE users SET company_id = $1 WHERE id = $2; - -- name: SuspendUser :exec UPDATE users SET suspended = $1, diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 37bdad1..d95a9db 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -1,4 +1,3 @@ - // Code generated by sqlc. DO NOT EDIT. // versions: // sqlc v1.28.0 diff --git a/gen/db/notification.sql.go b/gen/db/notification.sql.go index 5bfedd6..d30b3d1 100644 --- a/gen/db/notification.sql.go +++ b/gen/db/notification.sql.go @@ -11,12 +11,52 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const CountUnreadNotifications = `-- name: CountUnreadNotifications :one +SELECT count(id) +FROM notifications +WHERE recipient_id = $1 + AND is_read = false +` + +func (q *Queries) CountUnreadNotifications(ctx context.Context, recipientID int64) (int64, error) { + row := q.db.QueryRow(ctx, CountUnreadNotifications, recipientID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CreateNotification = `-- name: CreateNotification :one INSERT INTO notifications ( - id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, timestamp, metadata -) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 -) RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata + id, + recipient_id, + type, + level, + error_severity, + reciever, + is_read, + delivery_status, + delivery_channel, + payload, + priority, + timestamp, + metadata + ) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13 + ) +RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata ` type CreateNotificationParams struct { @@ -71,8 +111,58 @@ func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotification return i, err } +const GetAllNotifications = `-- name: GetAllNotifications :many +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +ORDER BY timestamp DESC +LIMIT $1 OFFSET $2 +` + +type GetAllNotificationsParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +func (q *Queries) GetAllNotifications(ctx context.Context, arg GetAllNotificationsParams) ([]Notification, error) { + rows, err := q.db.Query(ctx, GetAllNotifications, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Notification + for rows.Next() { + var i Notification + if err := rows.Scan( + &i.ID, + &i.RecipientID, + &i.Type, + &i.Level, + &i.ErrorSeverity, + &i.Reciever, + &i.IsRead, + &i.DeliveryStatus, + &i.DeliveryChannel, + &i.Payload, + &i.Priority, + &i.Version, + &i.Timestamp, + &i.Metadata, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetNotification = `-- name: GetNotification :one -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE id = $1 LIMIT 1 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE id = $1 +LIMIT 1 ` func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, error) { @@ -98,7 +188,12 @@ func (q *Queries) GetNotification(ctx context.Context, id string) (Notification, } const ListFailedNotifications = `-- name: ListFailedNotifications :many -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE delivery_status = 'failed' AND timestamp < NOW() - INTERVAL '1 hour' ORDER BY timestamp ASC LIMIT $1 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE delivery_status = 'failed' + AND timestamp < NOW() - INTERVAL '1 hour' +ORDER BY timestamp ASC +LIMIT $1 ` func (q *Queries) ListFailedNotifications(ctx context.Context, limit int32) ([]Notification, error) { @@ -137,7 +232,11 @@ func (q *Queries) ListFailedNotifications(ctx context.Context, limit int32) ([]N } const ListNotifications = `-- name: ListNotifications :many -SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata FROM notifications WHERE recipient_id = $1 ORDER BY timestamp DESC LIMIT $2 OFFSET $3 +SELECT id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +FROM notifications +WHERE recipient_id = $1 +ORDER BY timestamp DESC +LIMIT $2 OFFSET $3 ` type ListNotificationsParams struct { @@ -182,7 +281,9 @@ func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsPa } const ListRecipientIDsByReceiver = `-- name: ListRecipientIDsByReceiver :many -SELECT recipient_id FROM notifications WHERE reciever = $1 +SELECT recipient_id +FROM notifications +WHERE reciever = $1 ` func (q *Queries) ListRecipientIDsByReceiver(ctx context.Context, reciever string) ([]int64, error) { @@ -206,7 +307,12 @@ func (q *Queries) ListRecipientIDsByReceiver(ctx context.Context, reciever strin } const UpdateNotificationStatus = `-- name: UpdateNotificationStatus :one -UPDATE notifications SET delivery_status = $2, is_read = $3, metadata = $4 WHERE id = $1 RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata +UPDATE notifications +SET delivery_status = $2, + is_read = $3, + metadata = $4 +WHERE id = $1 +RETURNING id, recipient_id, type, level, error_severity, reciever, is_read, delivery_status, delivery_channel, payload, priority, version, timestamp, metadata ` type UpdateNotificationStatusParams struct { diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index a595372..f66aff0 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -182,14 +182,14 @@ wHERE ( company_id = $2 OR $2 IS NULL ) -LIMIT $3 OFFSET $4 +LIMIT $4 OFFSET $3 ` type GetAllUsersParams struct { Role string `json:"role"` CompanyID pgtype.Int8 `json:"company_id"` - Limit int32 `json:"limit"` - Offset int32 `json:"offset"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` } type GetAllUsersRow struct { @@ -212,8 +212,8 @@ func (q *Queries) GetAllUsers(ctx context.Context, arg GetAllUsersParams) ([]Get rows, err := q.db.Query(ctx, GetAllUsers, arg.Role, arg.CompanyID, - arg.Limit, arg.Offset, + arg.Limit, ) if err != nil { return nil, err diff --git a/internal/domain/common.go b/internal/domain/common.go index 14323a4..fc652d1 100644 --- a/internal/domain/common.go +++ b/internal/domain/common.go @@ -9,6 +9,10 @@ type ValidInt64 struct { Value int64 Valid bool } +type ValidInt struct { + Value int + Valid bool +} type ValidString struct { Value string diff --git a/internal/repository/notification.go b/internal/repository/notification.go index eb922a9..b189ccf 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -15,6 +15,8 @@ type NotificationRepository interface { ListNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, error) ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) + CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) + GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) } type Repository struct { @@ -105,6 +107,24 @@ func (r *Repository) ListNotifications(ctx context.Context, recipientID int64, l return result, nil } +func (r *Repository) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { + + dbNotifications, err := r.store.queries.GetAllNotifications(ctx, dbgen.GetAllNotificationsParams{ + Limit: int32(limit), + Offset: int32(offset), + }) + if err != nil { + return nil, err + } + + var result []domain.Notification = make([]domain.Notification, 0, len(dbNotifications)) + for _, dbNotif := range dbNotifications { + domainNotif := r.mapDBToDomain(&dbNotif) + result = append(result, *domainNotif) + } + return result, nil +} + func (r *Repository) ListFailedNotifications(ctx context.Context, limit int) ([]domain.Notification, error) { dbNotifications, err := r.store.queries.ListFailedNotifications(ctx, int32(limit)) if err != nil { @@ -177,3 +197,7 @@ func unmarshalPayload(data []byte) (domain.NotificationPayload, error) { } return payload, nil } + +func (r *Repository) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { + return r.store.queries.CountUnreadNotifications(ctx, recipient_id) +} diff --git a/internal/repository/user.go b/internal/repository/user.go index c2aa930..88a320b 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -90,8 +90,14 @@ func (s *Store) GetAllUsers(ctx context.Context, filter user.Filter) ([]domain.U Int64: filter.CompanyID.Value, Valid: filter.CompanyID.Valid, }, - Limit: int32(filter.PageSize), - Offset: int32(filter.Page), + Limit: pgtype.Int4{ + Int32: int32(filter.PageSize.Value), + Valid: filter.PageSize.Valid, + }, + Offset: pgtype.Int4{ + Int32: int32(filter.Page.Value), + Valid: filter.Page.Valid, + }, }) if err != nil { return nil, 0, err diff --git a/internal/services/notfication/port.go b/internal/services/notfication/port.go index 23120ee..ec82c03 100644 --- a/internal/services/notfication/port.go +++ b/internal/services/notfication/port.go @@ -16,4 +16,6 @@ type NotificationStore interface { SendSMS(ctx context.Context, recipientID int64, message string) error SendEmail(ctx context.Context, recipientID int64, subject, message string) error ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) // New method + CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) + GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) } diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go index 368e637..5d5760c 100644 --- a/internal/services/notfication/service.go +++ b/internal/services/notfication/service.go @@ -85,28 +85,31 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not return nil } -func (s *Service) MarkAsRead(ctx context.Context, notificationID string, recipientID int64) error { - _, err := s.repo.UpdateNotificationStatus(ctx, notificationID, string(domain.DeliveryStatusSent), true, nil) - if err != nil { - s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) - return err +func (s *Service) MarkAsRead(ctx context.Context, notificationIDs []string, recipientID int64) error { + for _, notificationID := range notificationIDs { + _, err := s.repo.UpdateNotificationStatus(ctx, notificationID, string(domain.DeliveryStatusSent), true, nil) + if err != nil { + s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) + return err + } + + // count, err := s.repo.CountUnreadNotifications(ctx, recipientID) + // if err != nil { + // s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err) + // return err + // } + + // s.Hub.Broadcast <- map[string]interface{}{ + // "type": "COUNT_NOT_OPENED_NOTIFICATION", + // "recipient_id": recipientID, + // "payload": map[string]int{ + // "not_opened_notifications_count": int(count), + // }, + // } + + s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) } - // count, err := s.repo.CountUnreadNotifications(ctx, recipientID) - // if err != nil { - // s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err) - // return err - // } - - // s.Hub.Broadcast <- map[string]interface{}{ - // "type": "COUNT_NOT_OPENED_NOTIFICATION", - // "recipient_id": recipientID, - // "payload": map[string]int{ - // "not_opened_notifications_count": int(count), - // }, - // } - - s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) return nil } @@ -120,6 +123,16 @@ func (s *Service) ListNotifications(ctx context.Context, recipientID int64, limi return notifications, nil } +func (s *Service) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { + notifications, err := s.repo.GetAllNotifications(ctx, limit, offset) + if err != nil { + s.logger.Error("[NotificationSvc.ListNotifications] Failed to get all notifications") + return nil, err + } + s.logger.Info("[NotificationSvc.ListNotifications] Successfully retrieved all notifications", "count", len(notifications)) + return notifications, nil +} + func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { s.addConnection(ctx, recipientID, c) s.logger.Info("[NotificationSvc.ConnectWebSocket] WebSocket connection established", "recipientID", recipientID) @@ -267,3 +280,6 @@ func (s *Service) retryFailedNotifications() { } } +func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { + return s.repo.CountUnreadNotifications(ctx, recipient_id) +} diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 8181822..04b8a65 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -46,8 +46,8 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error { type Filter struct { Role string CompanyID domain.ValidInt64 - Page int - PageSize int + Page domain.ValidInt + PageSize domain.ValidInt } type ValidRole struct { Value domain.Role diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index 2b8cdf0..3b5bbb6 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -124,14 +124,21 @@ type AdminRes struct { // @Failure 500 {object} response.APIResponse // @Router /admin [get] func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { + filter := user.Filter{ Role: string(domain.RoleAdmin), CompanyID: domain.ValidInt64{ Value: int64(c.QueryInt("company_id")), Valid: true, }, - Page: c.QueryInt("page", 1) - 1, - PageSize: c.QueryInt("page_size", 10), + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), + Valid: true, + }, } valErrs, ok := h.validator.Validate(c, filter) if !ok { @@ -171,6 +178,6 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { } } - return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page, int(total)) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page.Value, int(total)) } diff --git a/internal/web_server/handlers/manager.go b/internal/web_server/handlers/manager.go index 9edfbb6..0c3a980 100644 --- a/internal/web_server/handlers/manager.go +++ b/internal/web_server/handlers/manager.go @@ -115,8 +115,14 @@ func (h *Handler) GetAllManagers(c *fiber.Ctx) error { Value: int64(c.QueryInt("company_id")), Valid: true, }, - Page: c.QueryInt("page", 1) - 1, - PageSize: c.QueryInt("page_size", 10), + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), + Valid: true, + }, } valErrs, ok := h.validator.Validate(c, filter) if !ok { @@ -156,7 +162,7 @@ func (h *Handler) GetAllManagers(c *fiber.Ctx) error { } } - return response.WritePaginatedJSON(c, fiber.StatusOK, "Managers retrieved successfully", result, nil, filter.Page, int(total)) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Managers retrieved successfully", result, nil, filter.Page.Value, int(total)) } diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 3d61451..1e28543 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -3,11 +3,13 @@ package handlers import ( "context" "encoding/json" + "fmt" "net" "net/http" "strconv" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/adaptor" @@ -101,7 +103,7 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error { func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { type Request struct { - NotificationID string `json:"notification_id" validate:"required"` + NotificationIDs []string `json:"notification_ids" validate:"required"` } var req Request @@ -110,14 +112,15 @@ func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } - userID, ok := c.Locals("userID").(int64) + userID, ok := c.Locals("user_id").(int64) if !ok || userID == 0 { h.logger.Error("Invalid user ID in context") return fiber.NewError(fiber.StatusUnauthorized, "invalid user identification") } - if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationID, userID); err != nil { - h.logger.Error("Failed to mark notification as read", "notificationID", req.NotificationID, "error", err) + fmt.Printf("Notification IDs: %v \n", req.NotificationIDs) + if err := h.notificationSvc.MarkAsRead(context.Background(), req.NotificationIDs, userID); err != nil { + h.logger.Error("Failed to mark notifications as read", "notificationID", req.NotificationIDs, "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update notification status") } @@ -181,17 +184,21 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "Single notification sent successfully", "notification_id": notification.ID}) case domain.NotificationDeliverySchemeBulk: - recipients, err := h.getAllRecipientIDs(context.Background(), req.Reciever) + recipients, _, err := h.userSvc.GetAllUsers(context.Background(), user.Filter{ + Role: string(req.Reciever), + }) if err != nil { h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to fetch recipients for bulk notification", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch recipients") } + fmt.Printf("Number of Recipients %d \n", len(recipients)) + notificationIDs := make([]string, 0, len(recipients)) - for _, recipientID := range recipients { + for _, user := range recipients { notification := &domain.Notification{ ID: "", - RecipientID: recipientID, + RecipientID: user.ID, Type: req.Type, Level: req.Level, ErrorSeverity: req.ErrorSeverity, @@ -205,7 +212,7 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error { } if err := h.notificationSvc.SendNotification(context.Background(), notification); err != nil { - h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to send bulk notification", "recipientID", recipientID, "error", err) + h.logger.Error("[NotificationSvc.CreateAndSendNotification] Failed to send bulk notification", "UserID", user.ID, "error", err) continue } notificationIDs = append(notificationIDs, notification.ID) @@ -258,8 +265,60 @@ func (h *Handler) GetNotifications(c *fiber.Ctx) error { "limit": limit, "offset": offset, }) + } func (h *Handler) getAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) { return h.notificationSvc.ListRecipientIDs(ctx, receiver) } + +func (h *Handler) CountUnreadNotifications(c *fiber.Ctx) error { + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid user ID in context") + return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification") + } + + total, err := h.notificationSvc.CountUnreadNotifications(c.Context(), userID) + + if err != nil { + h.logger.Error("[NotificationSvc.CountUnreadNotifications] Failed to fetch unread notification count", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "unread": total, + }) +} + +func (h *Handler) GetAllNotifications(c *fiber.Ctx) error { + limitStr := c.Query("limit", "10") + pageStr := c.Query("page", "1") + + // Convert limit and offset to integers + limit, err := strconv.Atoi(limitStr) + if err != nil || limit <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid limit value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value") + } + page, err := strconv.Atoi(pageStr) + if err != nil || page <= 0 { + h.logger.Error("[NotificationSvc.GetNotifications] Invalid page value", "error", err) + return fiber.NewError(fiber.StatusBadRequest, "Invalid page value") + } + + notifications, err := h.notificationSvc.GetAllNotifications(context.Background(), limit, ((page - 1) * limit)) + if err != nil { + h.logger.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications") + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "notifications": notifications, + "total_count": len(notifications), + "limit": limit, + "page": page, + }) + +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index e631246..06611c9 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -177,7 +177,9 @@ func (a *App) initAppRoutes() { // Notification Routes a.fiber.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket) a.fiber.Get("/notifications", a.authMiddleware, h.GetNotifications) - a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead) + a.fiber.Get("/notifications/all", a.authMiddleware, h.GetAllNotifications) + a.fiber.Post("/notifications/mark-as-read", a.authMiddleware, h.MarkNotificationAsRead) + a.fiber.Get("/notifications/unread", a.authMiddleware, h.CountUnreadNotifications) a.fiber.Post("/notifications/create", h.CreateAndSendNotification) // Virtual Game Routes diff --git a/makefile b/makefile index 5b62eb3..10f54f7 100644 --- a/makefile +++ b/makefile @@ -46,11 +46,13 @@ swagger: .PHONY: db-up db-up: - @docker compose up -d postgres + @docker compose up -d postgres migrate .PHONY: db-down db-down: @docker compose down +postgres: + @docker exec -it fortunebet-backend-postgres-1 psql -U root -d gh .PHONY: sqlc-gen sqlc-gen: From 16768ad9243a7bf763952979dfd1b3f0dd078ba5 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Fri, 23 May 2025 12:00:04 +0300 Subject: [PATCH 13/15] fix: Update integration issues --- db/query/branch.sql | 9 - db/query/cashier.sql | 15 ++ db/query/user.sql | 8 +- gen/db/branch.sql.go | 87 --------- gen/db/cashier.sql.go | 173 ++++++++++++++++++ gen/db/user.sql.go | 24 +-- internal/domain/user.go | 17 ++ internal/repository/user.go | 40 +++- internal/services/bet/service.go | 3 + internal/services/user/direct.go | 6 +- internal/services/user/port.go | 3 +- internal/services/user/service.go | 1 - internal/web_server/handlers/admin.go | 152 ++++++++++++++- .../web_server/handlers/branch_handler.go | 3 +- internal/web_server/handlers/cashier.go | 118 ++++++++++-- .../web_server/handlers/company_handler.go | 2 +- internal/web_server/handlers/manager.go | 110 ++++++++++- internal/web_server/handlers/user.go | 6 +- internal/web_server/routes.go | 4 + 19 files changed, 619 insertions(+), 162 deletions(-) create mode 100644 db/query/cashier.sql create mode 100644 gen/db/cashier.sql.go diff --git a/db/query/branch.sql b/db/query/branch.sql index 422a612..bb01b26 100644 --- a/db/query/branch.sql +++ b/db/query/branch.sql @@ -55,15 +55,6 @@ SELECT branches.* FROM branch_cashiers JOIN branches ON branch_cashiers.branch_id = branches.id WHERE branch_cashiers.user_id = $1; --- name: GetCashiersByBranch :many -SELECT users.* -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -WHERE branch_cashiers.branch_id = $1; --- name: GetAllCashiers :many -SELECT users.* -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id; -- name: UpdateBranch :one UPDATE branches SET name = COALESCE(sqlc.narg(name), name), diff --git a/db/query/cashier.sql b/db/query/cashier.sql new file mode 100644 index 0000000..dcb8dfb --- /dev/null +++ b/db/query/cashier.sql @@ -0,0 +1,15 @@ +-- name: GetCashiersByBranch :many +SELECT users.* +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +WHERE branch_cashiers.branch_id = $1; +-- name: GetAllCashiers :many +SELECT users.*, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id; +-- name: GetCashierByID :one +SELECT users.*, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = $1; \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index 84cfe4a..3341656 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -100,11 +100,9 @@ WHERE first_name ILIKE '%' || $1 || '%' UPDATE users SET first_name = $1, last_name = $2, - email = $3, - phone_number = $4, - role = $5, - updated_at = $6 -WHERE id = $7; + suspended = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $4; -- name: UpdateUserCompany :exec UPDATE users SET company_id = $1 diff --git a/gen/db/branch.sql.go b/gen/db/branch.sql.go index cf16465..93e9b2b 100644 --- a/gen/db/branch.sql.go +++ b/gen/db/branch.sql.go @@ -190,49 +190,6 @@ func (q *Queries) GetAllBranches(ctx context.Context) ([]BranchDetail, error) { return items, nil } -const GetAllCashiers = `-- name: GetAllCashiers :many -SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -` - -func (q *Queries) GetAllCashiers(ctx context.Context) ([]User, error) { - rows, err := q.db.Query(ctx, GetAllCashiers) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.FirstName, - &i.LastName, - &i.Email, - &i.PhoneNumber, - &i.Role, - &i.Password, - &i.EmailVerified, - &i.PhoneVerified, - &i.CreatedAt, - &i.UpdatedAt, - &i.CompanyID, - &i.SuspendedAt, - &i.Suspended, - &i.ReferralCode, - &i.ReferredBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const GetAllSupportedOperations = `-- name: GetAllSupportedOperations :many SELECT id, name, description FROM supported_operations @@ -430,50 +387,6 @@ func (q *Queries) GetBranchOperations(ctx context.Context, branchID int64) ([]Ge return items, nil } -const GetCashiersByBranch = `-- name: GetCashiersByBranch :many -SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by -FROM branch_cashiers - JOIN users ON branch_cashiers.user_id = users.id -WHERE branch_cashiers.branch_id = $1 -` - -func (q *Queries) GetCashiersByBranch(ctx context.Context, branchID int64) ([]User, error) { - rows, err := q.db.Query(ctx, GetCashiersByBranch, branchID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []User - for rows.Next() { - var i User - if err := rows.Scan( - &i.ID, - &i.FirstName, - &i.LastName, - &i.Email, - &i.PhoneNumber, - &i.Role, - &i.Password, - &i.EmailVerified, - &i.PhoneVerified, - &i.CreatedAt, - &i.UpdatedAt, - &i.CompanyID, - &i.SuspendedAt, - &i.Suspended, - &i.ReferralCode, - &i.ReferredBy, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const SearchBranchByName = `-- name: SearchBranchByName :many SELECT id, name, location, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at, manager_name, manager_phone_number FROM branch_details diff --git a/gen/db/cashier.sql.go b/gen/db/cashier.sql.go new file mode 100644 index 0000000..d0f6768 --- /dev/null +++ b/gen/db/cashier.sql.go @@ -0,0 +1,173 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: cashier.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetAllCashiers = `-- name: GetAllCashiers :many +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +` + +type GetAllCashiersRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email pgtype.Text `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Role string `json:"role"` + Password []byte `json:"password"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CompanyID pgtype.Int8 `json:"company_id"` + SuspendedAt pgtype.Timestamptz `json:"suspended_at"` + Suspended bool `json:"suspended"` + ReferralCode pgtype.Text `json:"referral_code"` + ReferredBy pgtype.Text `json:"referred_by"` + BranchID int64 `json:"branch_id"` +} + +func (q *Queries) GetAllCashiers(ctx context.Context) ([]GetAllCashiersRow, error) { + rows, err := q.db.Query(ctx, GetAllCashiers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllCashiersRow + for rows.Next() { + var i GetAllCashiersRow + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + &i.BranchID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetCashierByID = `-- name: GetCashierByID :one +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by, + branch_id +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = $1 +` + +type GetCashierByIDRow struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email pgtype.Text `json:"email"` + PhoneNumber pgtype.Text `json:"phone_number"` + Role string `json:"role"` + Password []byte `json:"password"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CompanyID pgtype.Int8 `json:"company_id"` + SuspendedAt pgtype.Timestamptz `json:"suspended_at"` + Suspended bool `json:"suspended"` + ReferralCode pgtype.Text `json:"referral_code"` + ReferredBy pgtype.Text `json:"referred_by"` + BranchID int64 `json:"branch_id"` +} + +func (q *Queries) GetCashierByID(ctx context.Context, userID int64) (GetCashierByIDRow, error) { + row := q.db.QueryRow(ctx, GetCashierByID, userID) + var i GetCashierByIDRow + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + &i.BranchID, + ) + return i, err +} + +const GetCashiersByBranch = `-- name: GetCashiersByBranch :many +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by +FROM branch_cashiers + JOIN users ON branch_cashiers.user_id = users.id +WHERE branch_cashiers.branch_id = $1 +` + +func (q *Queries) GetCashiersByBranch(ctx context.Context, branchID int64) ([]User, error) { + rows, err := q.db.Query(ctx, GetCashiersByBranch, branchID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index f66aff0..dd2f985 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -532,31 +532,23 @@ const UpdateUser = `-- name: UpdateUser :exec UPDATE users SET first_name = $1, last_name = $2, - email = $3, - phone_number = $4, - role = $5, - updated_at = $6 -WHERE id = $7 + suspended = $3, + updated_at = CURRENT_TIMESTAMP +WHERE id = $4 ` type UpdateUserParams struct { - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email pgtype.Text `json:"email"` - PhoneNumber pgtype.Text `json:"phone_number"` - Role string `json:"role"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Suspended bool `json:"suspended"` + ID int64 `json:"id"` } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error { _, err := q.db.Exec(ctx, UpdateUser, arg.FirstName, arg.LastName, - arg.Email, - arg.PhoneNumber, - arg.Role, - arg.UpdatedAt, + arg.Suspended, arg.ID, ) return err diff --git a/internal/domain/user.go b/internal/domain/user.go index 1cd27d6..bdafe6c 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -62,9 +62,26 @@ type UpdateUserReq struct { FirstName ValidString LastName ValidString Suspended ValidBool + CompanyID ValidInt64 } type UpdateUserReferalCode struct { UserID int64 Code string } + +type GetCashier struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` + BranchID int64 `json:"branch_id"` +} diff --git a/internal/repository/user.go b/internal/repository/user.go index 88a320b..df82a40 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" @@ -129,14 +130,14 @@ func (s *Store) GetAllUsers(ctx context.Context, filter user.Filter) ([]domain.U return userList, totalCount, nil } -func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.User, error) { +func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) { users, err := s.queries.GetAllCashiers(ctx) if err != nil { return nil, err } - userList := make([]domain.User, len(users)) + userList := make([]domain.GetCashier, len(users)) for i, user := range users { - userList[i] = domain.User{ + userList[i] = domain.GetCashier{ ID: user.ID, FirstName: user.FirstName, LastName: user.LastName, @@ -154,6 +155,28 @@ func (s *Store) GetAllCashiers(ctx context.Context) ([]domain.User, error) { return userList, nil } +func (s *Store) GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) { + user, err := s.queries.GetCashierByID(ctx, cashierID) + if err != nil { + return domain.GetCashier{}, err + } + return domain.GetCashier{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email.String, + PhoneNumber: user.PhoneNumber.String, + Role: domain.Role(user.Role), + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt.Time, + UpdatedAt: user.UpdatedAt.Time, + SuspendedAt: user.SuspendedAt.Time, + Suspended: user.Suspended, + BranchID: user.BranchID, + }, nil +} + func (s *Store) GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) { users, err := s.queries.GetCashiersByBranch(ctx, branchID) if err != nil { @@ -210,13 +233,12 @@ func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string func (s *Store) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { err := s.queries.UpdateUser(ctx, dbgen.UpdateUserParams{ - // ID: user.ID, - // FirstName: user.FirstName, - // LastName: user.LastName, - // Email: user.Email, - // PhoneNumber: user.PhoneNumber, - + ID: user.UserId, + FirstName: user.FirstName.Value, + LastName: user.LastName.Value, + Suspended: user.Suspended.Value, }) + fmt.Printf("Updating User %v with values %v", user.UserId, user) if err != nil { return err } diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index a644021..6e5b2d5 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -219,6 +219,9 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID } newBet.IsShopBet = true case domain.RoleCustomer: + // Get User Wallet + + return domain.CreateBetRes{}, fmt.Errorf("Not yet implemented") default: return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 04b8a65..c61cd01 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -71,6 +71,10 @@ func (s *Service) GetCashiersByBranch(ctx context.Context, branchID int64) ([]do return s.userStore.GetCashiersByBranch(ctx, branchID) } -func (s *Service) GetAllCashiers(ctx context.Context) ([]domain.User, error) { +func (s *Service) GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) { return s.userStore.GetAllCashiers(ctx) } + +func (s *Service) GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) { + return s.userStore.GetCashierByID(ctx, cashierID) +} diff --git a/internal/services/user/port.go b/internal/services/user/port.go index c7d1bfb..f6adec0 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -11,7 +11,8 @@ type UserStore interface { CreateUserWithoutOtp(ctx context.Context, user domain.User, is_company bool) (domain.User, error) GetUserByID(ctx context.Context, id int64) (domain.User, error) GetAllUsers(ctx context.Context, filter Filter) ([]domain.User, int64, error) - GetAllCashiers(ctx context.Context) ([]domain.User, error) + GetAllCashiers(ctx context.Context) ([]domain.GetCashier, error) + GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error UpdateUserCompany(ctx context.Context, id int64, companyID int64) error diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 17a7820..cfa93fd 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -13,7 +13,6 @@ type Service struct { otpStore OtpStore smsGateway SmsGateway emailGateway EmailGateway - } func NewService( diff --git a/internal/web_server/handlers/admin.go b/internal/web_server/handlers/admin.go index 3b5bbb6..795a61f 100644 --- a/internal/web_server/handlers/admin.go +++ b/internal/web_server/handlers/admin.go @@ -2,6 +2,7 @@ package handlers import ( "log/slog" + "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -129,7 +130,7 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { Role: string(domain.RoleAdmin), CompanyID: domain.ValidInt64{ Value: int64(c.QueryInt("company_id")), - Valid: true, + Valid: false, }, Page: domain.ValidInt{ Value: c.QueryInt("page", 1) - 1, @@ -179,5 +180,154 @@ func (h *Handler) GetAllAdmins(c *fiber.Ctx) error { } return response.WritePaginatedJSON(c, fiber.StatusOK, "Admins retrieved successfully", result, nil, filter.Page.Value, int(total)) +} + +// GetAdminByID godoc +// @Summary Get admin by id +// @Description Get a single admin by id +// @Tags admin +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} AdminRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/{id} [get] +func (h *Handler) GetAdminByID(c *fiber.Ctx) error { + // branchId := int64(12) //c.Locals("branch_id").(int64) + // filter := user.Filter{ + // Role: string(domain.RoleUser), + // BranchId: user.ValidBranchId{ + // Value: branchId, + // Valid: true, + // }, + // Page: c.QueryInt("page", 1), + // PageSize: c.QueryInt("page_size", 10), + // } + // valErrs, ok := validator.Validate(c, filter) + // if !ok { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + // } + + userIDstr := c.Params("id") + userID, err := strconv.ParseInt(userIDstr, 10, 64) + if err != nil { + h.logger.Error("failed to fetch user using UserID", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid admin ID", nil, nil) + } + + user, err := h.userSvc.GetUserByID(c.Context(), userID) + if err != nil { + h.logger.Error("Get User By ID failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get admin", nil, nil) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + + lastLogin = &user.CreatedAt + } + + res := AdminRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + } + + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateAdminReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Suspended bool `json:"suspended" example:"false"` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` +} + +// UpdateAdmin godoc +// @Summary Update Admin +// @Description Update Admin +// @Tags admin +// @Accept json +// @Produce json +// @Param admin body updateAdminReq true "Update Admin" +// @Success 200 {object} response.APIResponse +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /admin/{id} [put] +func (h *Handler) UpdateAdmin(c *fiber.Ctx) error { + var req updateAdminReq + if err := c.BodyParser(&req); err != nil { + h.logger.Error("UpdateAdmin failed", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) + } + + valErrs, ok := h.validator.Validate(c, req) + + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + AdminIDStr := c.Params("id") + AdminID, err := strconv.ParseInt(AdminIDStr, 10, 64) + if err != nil { + h.logger.Error("UpdateAdmin failed", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Admin ID", nil, nil) + } + var companyID domain.ValidInt64 + if req.CompanyID != nil { + companyID = domain.ValidInt64{ + Value: *req.CompanyID, + Valid: true, + } + } + err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ + UserId: AdminID, + FirstName: domain.ValidString{ + Value: req.FirstName, + Valid: req.FirstName != "", + }, + LastName: domain.ValidString{ + Value: req.LastName, + Valid: req.LastName != "", + }, + Suspended: domain.ValidBool{ + Value: req.Suspended, + Valid: true, + }, + CompanyID: companyID, + }, + ) + if err != nil { + h.logger.Error("UpdateAdmin failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update admin", nil, nil) + } + if req.CompanyID != nil { + _, err := h.companySvc.UpdateCompany(c.Context(), domain.UpdateCompany{ + ID: *req.CompanyID, + AdminID: &AdminID, + }) + if err != nil { + h.logger.Error("CreateAdmin failed to update company", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to update company", nil, nil) + } + } + + return response.WriteJSON(c, fiber.StatusOK, "Managers updated successfully", nil, nil) } diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index 8f090ee..395ba19 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -142,7 +142,8 @@ func (h *Handler) CreateBranch(c *fiber.Ctx) error { checkedCompanyID = *req.CompanyID } else { IsSelfOwned = false - checkedCompanyID = companyID.Value //the company id is always valid when its not a super admin + checkedCompanyID = companyID.Value + //TODO:check that the company id is always valid when its not a super admin } // Create Branch Wallet diff --git a/internal/web_server/handlers/cashier.go b/internal/web_server/handlers/cashier.go index 8a99d3b..4fdebfc 100644 --- a/internal/web_server/handlers/cashier.go +++ b/internal/web_server/handlers/cashier.go @@ -7,6 +7,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -87,6 +88,7 @@ type GetCashierRes struct { SuspendedAt time.Time `json:"suspended_at"` Suspended bool `json:"suspended"` LastLogin time.Time `json:"last_login"` + BranchID int64 `json:"branch_id"` } // GetAllCashiers godoc @@ -103,22 +105,31 @@ type GetCashierRes struct { // @Failure 500 {object} response.APIResponse // @Router /cashiers [get] func (h *Handler) GetAllCashiers(c *fiber.Ctx) error { - // branchId := int64(12) //c.Locals("branch_id").(int64) - // filter := user.Filter{ - // Role: string(domain.RoleCashier), - // BranchId: user.ValidBranchId{ - // Value: branchId, - // Valid: true, - // }, - // Page: c.QueryInt("page", 1), - // PageSize: c.QueryInt("page_size", 10), - // } - // valErrs, ok := validator.Validate(c, filter) - // if !ok { - // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) - // } + role := c.Locals("role").(domain.Role) + companyId := c.Locals("company_id").(domain.ValidInt64) - cashiers, err := h.userSvc.GetAllCashiers(c.Context()) + if role != domain.RoleSuperAdmin && !companyId.Valid { + return fiber.NewError(fiber.StatusInternalServerError, "Cannot get company ID") + } + filter := user.Filter{ + Role: string(domain.RoleCashier), + CompanyID: companyId, + Page: domain.ValidInt{ + Value: c.QueryInt("page", 1) - 1, + Valid: true, + }, + PageSize: domain.ValidInt{ + Value: c.QueryInt("page_size", 10), + Valid: true, + }, + } + + valErrs, ok := h.validator.Validate(c, filter) + if !ok { + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + } + + cashiers, total, err := h.userSvc.GetAllUsers(c.Context(), filter) if err != nil { h.logger.Error("GetAllCashiers failed", "error", err) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", nil, nil) @@ -154,11 +165,80 @@ func (h *Handler) GetAllCashiers(c *fiber.Ctx) error { }) } - return response.WriteJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", result, nil) + return response.WritePaginatedJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", result, nil, filter.Page.Value, int(total)) } -type updateUserReq struct { +// GetCashierByID godoc +// @Summary Get cashier by id +// @Description Get a single cashier by id +// @Tags cashier +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} UserProfileRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /cashier/{id} [get] +func (h *Handler) GetCashierByID(c *fiber.Ctx) error { + // branchId := int64(12) //c.Locals("branch_id").(int64) + // filter := user.Filter{ + // Role: string(domain.RoleUser), + // BranchId: user.ValidBranchId{ + // Value: branchId, + // Valid: true, + // }, + // Page: c.QueryInt("page", 1), + // PageSize: c.QueryInt("page_size", 10), + // } + // valErrs, ok := validator.Validate(c, filter) + // if !ok { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + // } + + stringID := c.Params("id") + cashierID, err := strconv.ParseInt(stringID, 10, 64) + if err != nil { + h.logger.Error("failed to fetch user using UserID", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) + } + + user, err := h.userSvc.GetCashierByID(c.Context(), cashierID) + if err != nil { + h.logger.Error("Get User By ID failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", nil, nil) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.logger.Error("Failed to get user last login", "cashierID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + lastLogin = &user.CreatedAt + } + + res := GetCashierRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + BranchID: user.BranchID, + } + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateCashierReq struct { FirstName string `json:"first_name" example:"John"` LastName string `json:"last_name" example:"Doe"` Suspended bool `json:"suspended" example:"false"` @@ -171,7 +251,7 @@ type updateUserReq struct { // @Accept json // @Produce json // @Param id path int true "Cashier ID" -// @Param cashier body updateUserReq true "Update cashier" +// @Param cashier body updateCashierReq true "Update cashier" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse @@ -184,7 +264,7 @@ func (h *Handler) UpdateCashier(c *fiber.Ctx) error { h.logger.Error("UpdateCashier failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) } - var req updateUserReq + var req updateCashierReq if err := c.BodyParser(&req); err != nil { h.logger.Error("UpdateCashier failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) diff --git a/internal/web_server/handlers/company_handler.go b/internal/web_server/handlers/company_handler.go index 46b8a7d..2555cdd 100644 --- a/internal/web_server/handlers/company_handler.go +++ b/internal/web_server/handlers/company_handler.go @@ -241,7 +241,7 @@ func (h *Handler) UpdateCompany(c *fiber.Ctx) error { var req UpdateCompanyReq if err := c.BodyParser(&req); err != nil { - h.logger.Error("CreateCompanyReq failed", "error", err) + h.logger.Error("UpdateCompanyReq failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", err, nil) } valErrs, ok := h.validator.Validate(c, req) diff --git a/internal/web_server/handlers/manager.go b/internal/web_server/handlers/manager.go index 0c3a980..6d35089 100644 --- a/internal/web_server/handlers/manager.go +++ b/internal/web_server/handlers/manager.go @@ -109,12 +109,15 @@ type ManagersRes struct { // @Failure 500 {object} response.APIResponse // @Router /managers [get] func (h *Handler) GetAllManagers(c *fiber.Ctx) error { + role := c.Locals("role").(domain.Role) + companyId := c.Locals("company_id").(domain.ValidInt64) + + if role != domain.RoleSuperAdmin && !companyId.Valid { + return fiber.NewError(fiber.StatusInternalServerError, "Cannot get company ID") + } filter := user.Filter{ - Role: string(domain.RoleBranchManager), - CompanyID: domain.ValidInt64{ - Value: int64(c.QueryInt("company_id")), - Valid: true, - }, + Role: string(domain.RoleBranchManager), + CompanyID: companyId, Page: domain.ValidInt{ Value: c.QueryInt("page", 1) - 1, Valid: true, @@ -166,20 +169,97 @@ func (h *Handler) GetAllManagers(c *fiber.Ctx) error { } +// GetManagerByID godoc +// @Summary Get manager by id +// @Description Get a single manager by id +// @Tags manager +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} ManagerRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /managers/{id} [get] +func (h *Handler) GetManagerByID(c *fiber.Ctx) error { + // branchId := int64(12) //c.Locals("branch_id").(int64) + // filter := user.Filter{ + // Role: string(domain.RoleUser), + // BranchId: user.ValidBranchId{ + // Value: branchId, + // Valid: true, + // }, + // Page: c.QueryInt("page", 1), + // PageSize: c.QueryInt("page_size", 10), + // } + // valErrs, ok := validator.Validate(c, filter) + // if !ok { + // return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) + // } + + userIDstr := c.Params("id") + userID, err := strconv.ParseInt(userIDstr, 10, 64) + if err != nil { + h.logger.Error("failed to fetch user using UserID", "error", err) + return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid managers ID", nil, nil) + } + + user, err := h.userSvc.GetUserByID(c.Context(), userID) + if err != nil { + h.logger.Error("Get User By ID failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get managers", nil, nil) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.logger.Error("Failed to get user last login", "userID", user.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login") + } + + lastLogin = &user.CreatedAt + } + + res := ManagersRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + } + + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) +} + +type updateManagerReq struct { + FirstName string `json:"first_name" example:"John"` + LastName string `json:"last_name" example:"Doe"` + Suspended bool `json:"suspended" example:"false"` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` +} + // UpdateManagers godoc // @Summary Update Managers // @Description Update Managers -// @Tags Managers +// @Tags manager // @Accept json // @Produce json -// @Param Managers body updateUserReq true "Update Managers" +// @Param Managers body updateManagerReq true "Update Managers" // @Success 200 {object} response.APIResponse // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /managers/{id} [put] func (h *Handler) UpdateManagers(c *fiber.Ctx) error { - var req updateUserReq + var req updateManagerReq if err := c.BodyParser(&req); err != nil { h.logger.Error("UpdateManagers failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", nil, nil) @@ -196,6 +276,19 @@ func (h *Handler) UpdateManagers(c *fiber.Ctx) error { h.logger.Error("UpdateManagers failed", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid Managers ID", nil, nil) } + var companyID domain.ValidInt64 + role := c.Locals("role").(domain.Role) + if req.CompanyID != nil { + if role != domain.RoleSuperAdmin { + h.logger.Error("UpdateManagers failed", "error", err) + return response.WriteJSON(c, fiber.StatusUnauthorized, "This user role cannot modify company ID", nil, nil) + } + companyID = domain.ValidInt64{ + Value: *req.CompanyID, + Valid: true, + } + } + err = h.userSvc.UpdateUser(c.Context(), domain.UpdateUserReq{ UserId: ManagersId, FirstName: domain.ValidString{ @@ -210,6 +303,7 @@ func (h *Handler) UpdateManagers(c *fiber.Ctx) error { Value: req.Suspended, Valid: true, }, + CompanyID: companyID, }, ) if err != nil { diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 55de2af..8e280fa 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -474,13 +474,13 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { userIDstr := c.Params("id") userID, err := strconv.ParseInt(userIDstr, 10, 64) if err != nil { - h.logger.Error("UpdateCashier failed", "error", err) + h.logger.Error("failed to fetch user using UserID", "error", err) return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid cashier ID", nil, nil) } user, err := h.userSvc.GetUserByID(c.Context(), userID) if err != nil { - h.logger.Error("GetAllCashiers failed", "error", err) + h.logger.Error("Get User By ID failed", "error", err) return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to get cashiers", nil, nil) } @@ -510,7 +510,7 @@ func (h *Handler) GetUserByID(c *fiber.Ctx) error { LastLogin: *lastLogin, } - return response.WriteJSON(c, fiber.StatusOK, "Cashiers retrieved successfully", res, nil) + return response.WriteJSON(c, fiber.StatusOK, "User retrieved successfully", res, nil) } diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 06611c9..51a9d1c 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -90,13 +90,17 @@ func (a *App) initAppRoutes() { a.fiber.Patch("/referral/settings", a.authMiddleware, h.UpdateReferralSettings) a.fiber.Get("/cashiers", a.authMiddleware, h.GetAllCashiers) + a.fiber.Get("/cashiers/:id", a.authMiddleware, h.GetCashierByID) a.fiber.Post("/cashiers", a.authMiddleware, h.CreateCashier) a.fiber.Put("/cashiers/:id", a.authMiddleware, h.UpdateCashier) a.fiber.Get("/admin", a.authMiddleware, h.GetAllAdmins) + a.fiber.Get("/admin/:id", a.authMiddleware, h.GetAdminByID) a.fiber.Post("/admin", a.authMiddleware, h.CreateAdmin) + a.fiber.Put("/admin/:id", a.authMiddleware, h.UpdateAdmin) a.fiber.Get("/managers", a.authMiddleware, h.GetAllManagers) + a.fiber.Get("/managers/:id", a.authMiddleware, h.GetManagerByID) a.fiber.Post("/managers", a.authMiddleware, h.CreateManager) a.fiber.Put("/managers/:id", a.authMiddleware, h.UpdateManagers) a.fiber.Get("/manager/:id/branch", a.authMiddleware, h.GetBranchByManagerID) From 1be3ffdc3c5344b2fedfa20b3e1e3d9d992bda01 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Fri, 23 May 2025 18:21:37 +0300 Subject: [PATCH 14/15] fix: customer auth integration --- db/migrations/000001_fortune.up.sql | 1 - db/query/wallet.sql | 3 +- internal/domain/wallet.go | 3 -- internal/repository/wallet.go | 3 -- internal/services/bet/service.go | 30 +++++++++++++++++-- internal/services/wallet/wallet.go | 5 +--- internal/web_server/handlers/user.go | 26 +++++++++------- .../web_server/handlers/wallet_handler.go | 20 ++++++------- 8 files changed, 53 insertions(+), 38 deletions(-) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index a2568d1..64f3c2d 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -114,7 +114,6 @@ CREATE TABLE IF NOT EXISTS wallets ( CREATE TABLE IF NOT EXISTS customer_wallets ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL, - company_id BIGINT NOT NULL, regular_wallet_id BIGINT NOT NULL, static_wallet_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/db/query/wallet.sql b/db/query/wallet.sql index 0f4d27d..9030134 100644 --- a/db/query/wallet.sql +++ b/db/query/wallet.sql @@ -10,11 +10,10 @@ RETURNING *; -- name: CreateCustomerWallet :one INSERT INTO customer_wallets ( customer_id, - company_id, regular_wallet_id, static_wallet_id ) -VALUES ($1, $2, $3, $4) +VALUES ($1, $2, $3) RETURNING *; -- name: GetAllWallets :many SELECT * diff --git a/internal/domain/wallet.go b/internal/domain/wallet.go index 33e9466..387dbd7 100644 --- a/internal/domain/wallet.go +++ b/internal/domain/wallet.go @@ -19,7 +19,6 @@ type CustomerWallet struct { RegularID int64 StaticID int64 CustomerID int64 - CompanyID int64 } type GetCustomerWallet struct { ID int64 @@ -28,7 +27,6 @@ type GetCustomerWallet struct { StaticID int64 StaticBalance Currency CustomerID int64 - CompanyID int64 RegularUpdatedAt time.Time StaticUpdatedAt time.Time CreatedAt time.Time @@ -56,7 +54,6 @@ type CreateWallet struct { type CreateCustomerWallet struct { CustomerID int64 - CompanyID int64 RegularWalletID int64 StaticWalletID int64 } diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go index 86bf670..e61fb74 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -36,13 +36,11 @@ func convertDBCustomerWallet(customerWallet dbgen.CustomerWallet) domain.Custome RegularID: customerWallet.RegularWalletID, StaticID: customerWallet.StaticWalletID, CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, } } func convertCreateCustomerWallet(customerWallet domain.CreateCustomerWallet) dbgen.CreateCustomerWalletParams { return dbgen.CreateCustomerWalletParams{ CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, RegularWalletID: customerWallet.RegularWalletID, StaticWalletID: customerWallet.StaticWalletID, } @@ -56,7 +54,6 @@ func convertDBGetCustomerWallet(customerWallet dbgen.GetCustomerWalletRow) domai StaticID: customerWallet.StaticID, StaticBalance: domain.Currency(customerWallet.StaticBalance), CustomerID: customerWallet.CustomerID, - CompanyID: customerWallet.CompanyID, RegularUpdatedAt: customerWallet.RegularUpdatedAt.Time, StaticUpdatedAt: customerWallet.StaticUpdatedAt.Time, CreatedAt: customerWallet.CreatedAt.Time, diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 6e5b2d5..8f98433 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -209,8 +209,21 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return domain.CreateBetRes{}, ErrBranchIDRequired } + branch, err := s.branchSvc.GetBranchByID(ctx, *req.BranchID) + if err != nil { + return domain.CreateBetRes{}, err + } + // Deduct from wallet: + // TODO: Make this percentage come from the company + var deductedAmount = req.Amount / 10 + err = s.walletSvc.DeductFromWallet(ctx, branch.WalletID, domain.ToCurrency(deductedAmount)) + + if err != nil { + return domain.CreateBetRes{}, err + } + newBet.BranchID = domain.ValidInt64{ - Value: *req.BranchID, + Value: branch.ID, Valid: true, } newBet.UserID = domain.ValidInt64{ @@ -221,8 +234,19 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID case domain.RoleCustomer: // Get User Wallet - - return domain.CreateBetRes{}, fmt.Errorf("Not yet implemented") + wallet, err := s.walletSvc.GetWalletsByUser(ctx, userID) + + if err != nil { + return domain.CreateBetRes{}, err + } + + userWallet := wallet[0] + + err = s.walletSvc.DeductFromWallet(ctx, userWallet.ID, domain.ToCurrency(req.Amount)) + if err != nil { + return domain.CreateBetRes{}, err + } + default: return domain.CreateBetRes{}, fmt.Errorf("Unknown Role Type") } diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index ced664d..4749af2 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -15,7 +15,7 @@ func (s *Service) CreateWallet(ctx context.Context, wallet domain.CreateWallet) return s.walletStore.CreateWallet(ctx, wallet) } -func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.CustomerWallet, error) { +func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64) (domain.CustomerWallet, error) { regularWallet, err := s.CreateWallet(ctx, domain.CreateWallet{ IsWithdraw: true, @@ -39,7 +39,6 @@ func (s *Service) CreateCustomerWallet(ctx context.Context, customerID int64, co return s.walletStore.CreateCustomerWallet(ctx, domain.CreateCustomerWallet{ CustomerID: customerID, - CompanyID: companyID, RegularWalletID: regularWallet.ID, StaticWalletID: staticWallet.ID, }) @@ -91,8 +90,6 @@ func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain. return s.walletStore.UpdateBalance(ctx, id, wallet.Balance+amount) } - - func (s *Service) UpdateWalletActive(ctx context.Context, id int64, isActive bool) error { return s.walletStore.UpdateWalletActive(ctx, id, isActive) } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 8e280fa..4d6efcf 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -150,9 +150,7 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { medium, err := getMedium(req.Email, req.PhoneNumber) if err != nil { h.logger.Error("RegisterUser failed", "error", err) - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": err.Error(), - }) + return fiber.NewError(fiber.StatusBadRequest, err.Error()) } user.OtpMedium = medium @@ -160,24 +158,22 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { newUser, err := h.userSvc.RegisterUser(c.Context(), user) if err != nil { if errors.Is(err, domain.ErrOtpAlreadyUsed) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Otp already used", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Otp already used") } if errors.Is(err, domain.ErrOtpExpired) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Otp expired", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Otp expired") } if errors.Is(err, domain.ErrInvalidOtp) { - return response.WriteJSON(c, fiber.StatusBadRequest, "Invalid otp", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "Invalid otp") } if errors.Is(err, domain.ErrOtpNotFound) { - return response.WriteJSON(c, fiber.StatusBadRequest, "User already exist", nil, nil) + return fiber.NewError(fiber.StatusBadRequest, "User already exist") } h.logger.Error("RegisterUser failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": "Internal server error", - }) + return fiber.NewError(fiber.StatusInternalServerError, "Unknown Error") } - _, err = h.walletSvc.CreateWallet(c.Context(), domain.CreateWallet{ + newWallet, err := h.walletSvc.CreateWallet(c.Context(), domain.CreateWallet{ UserID: newUser.ID, IsWithdraw: true, IsBettable: true, @@ -194,6 +190,14 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { } } + // TODO: Remove later + err = h.walletSvc.AddToWallet(c.Context(), newWallet.ID, domain.ToCurrency(100.0)) + + if err != nil { + h.logger.Error("Failed to update wallet for user", "userID", newUser.ID, "error", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update user wallet") + } + return response.WriteJSON(c, fiber.StatusOK, "Registration successful", nil, nil) } diff --git a/internal/web_server/handlers/wallet_handler.go b/internal/web_server/handlers/wallet_handler.go index cf1bce6..f19fd9f 100644 --- a/internal/web_server/handlers/wallet_handler.go +++ b/internal/web_server/handlers/wallet_handler.go @@ -45,7 +45,6 @@ type CustomerWalletRes struct { StaticID int64 `json:"static_id" example:"1"` StaticBalance float32 `json:"static_balance" example:"100.0"` CustomerID int64 `json:"customer_id" example:"1"` - CompanyID int64 `json:"company_id" example:"1"` RegularUpdatedAt time.Time `json:"regular_updated_at"` StaticUpdatedAt time.Time `json:"static_updated_at"` CreatedAt time.Time `json:"created_at"` @@ -59,7 +58,6 @@ func convertCustomerWallet(wallet domain.GetCustomerWallet) CustomerWalletRes { StaticID: wallet.StaticID, StaticBalance: wallet.StaticBalance.Float32(), CustomerID: wallet.CustomerID, - CompanyID: wallet.CompanyID, RegularUpdatedAt: wallet.RegularUpdatedAt, StaticUpdatedAt: wallet.StaticUpdatedAt, CreatedAt: wallet.CreatedAt, @@ -249,21 +247,21 @@ func (h *Handler) GetCustomerWallet(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized access") } - companyID, err := strconv.ParseInt(c.Get("company_id"), 10, 64) - if err != nil { - h.logger.Error("Invalid company_id header", "value", c.Get("company_id"), "error", err) - return fiber.NewError(fiber.StatusBadRequest, "Invalid company_id") - } + // companyID, err := strconv.ParseInt(c.Get("company_id"), 10, 64) + // if err != nil { + // h.logger.Error("Invalid company_id header", "value", c.Get("company_id"), "error", err) + // return fiber.NewError(fiber.StatusBadRequest, "Invalid company_id") + // } - h.logger.Info("Fetching customer wallet", "userID", userID, "companyID", companyID) + h.logger.Info("Fetching customer wallet", "userID", userID) - wallet, err := h.walletSvc.GetCustomerWallet(c.Context(), userID, companyID) + wallet, err := h.walletSvc.GetWalletsByUser(c.Context(), userID) if err != nil { - h.logger.Error("Failed to get customer wallet", "userID", userID, "companyID", companyID, "error", err) + h.logger.Error("Failed to get customer wallet", "userID", userID, "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve wallet") } - res := convertCustomerWallet(wallet) + res := convertWallet(wallet[0]) return response.WriteJSON(c, fiber.StatusOK, "Wallet retrieved successfully", res, nil) } From eafd68d3c2c100d8ed14f93206bd2df70f40ac5b Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Fri, 23 May 2025 21:43:34 +0300 Subject: [PATCH 15/15] fix: restricting search --- db/query/user.sql | 16 +++++++++--- db/query/wallet.sql | 4 +-- gen/db/models.go | 1 - gen/db/user.sql.go | 26 ++++++++++++++++---- gen/db/wallet.sql.go | 27 ++++----------------- internal/repository/user.go | 27 +++++++++++++++++---- internal/repository/wallet.go | 7 ++---- internal/services/bet/service.go | 3 ++- internal/services/odds/service.go | 1 - internal/services/user/port.go | 2 +- internal/services/user/user.go | 4 +-- internal/services/wallet/port.go | 2 +- internal/services/wallet/wallet.go | 4 +-- internal/web_server/handlers/bet_handler.go | 7 +++++- internal/web_server/handlers/user.go | 6 +++-- 15 files changed, 82 insertions(+), 55 deletions(-) diff --git a/db/query/user.sql b/db/query/user.sql index 3341656..91ddccb 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -93,9 +93,19 @@ SELECT id, suspended_at, company_id FROM users -WHERE first_name ILIKE '%' || $1 || '%' - OR last_name ILIKE '%' || $1 || '%' - OR phone_number LIKE '%' || $1 || '%'; +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR phone_number LIKE '%' || $1 || '%' + ) + AND ( + role = sqlc.narg('role') + OR sqlc.narg('role') IS NULL + ) + AND ( + company_id = sqlc.narg('company_id') + OR sqlc.narg('company_id') IS NULL + ); -- name: UpdateUser :exec UPDATE users SET first_name = $1, diff --git a/db/query/wallet.sql b/db/query/wallet.sql index 9030134..e825653 100644 --- a/db/query/wallet.sql +++ b/db/query/wallet.sql @@ -29,7 +29,6 @@ WHERE user_id = $1; -- name: GetCustomerWallet :one SELECT cw.id, cw.customer_id, - cw.company_id, rw.id AS regular_id, rw.balance AS regular_balance, sw.id AS static_id, @@ -40,8 +39,7 @@ SELECT cw.id, FROM customer_wallets cw JOIN wallets rw ON cw.regular_wallet_id = rw.id JOIN wallets sw ON cw.static_wallet_id = sw.id -WHERE cw.customer_id = $1 - AND cw.company_id = $2; +WHERE cw.customer_id = $1; -- name: GetAllBranchWallets :many SELECT wallets.id, wallets.balance, diff --git a/gen/db/models.go b/gen/db/models.go index 9b27432..7c1695f 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -168,7 +168,6 @@ type Company struct { type CustomerWallet struct { ID int64 `json:"id"` CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularWalletID int64 `json:"regular_wallet_id"` StaticWalletID int64 `json:"static_wallet_id"` CreatedAt pgtype.Timestamp `json:"created_at"` diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index dd2f985..e0860c6 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -427,11 +427,27 @@ SELECT id, suspended_at, company_id FROM users -WHERE first_name ILIKE '%' || $1 || '%' - OR last_name ILIKE '%' || $1 || '%' - OR phone_number LIKE '%' || $1 || '%' +WHERE ( + first_name ILIKE '%' || $1 || '%' + OR last_name ILIKE '%' || $1 || '%' + OR phone_number LIKE '%' || $1 || '%' + ) + AND ( + role = $2 + OR $2 IS NULL + ) + AND ( + company_id = $3 + OR $3 IS NULL + ) ` +type SearchUserByNameOrPhoneParams struct { + Column1 pgtype.Text `json:"column_1"` + Role pgtype.Text `json:"role"` + CompanyID pgtype.Int8 `json:"company_id"` +} + type SearchUserByNameOrPhoneRow struct { ID int64 `json:"id"` FirstName string `json:"first_name"` @@ -448,8 +464,8 @@ type SearchUserByNameOrPhoneRow struct { CompanyID pgtype.Int8 `json:"company_id"` } -func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, dollar_1 pgtype.Text) ([]SearchUserByNameOrPhoneRow, error) { - rows, err := q.db.Query(ctx, SearchUserByNameOrPhone, dollar_1) +func (q *Queries) SearchUserByNameOrPhone(ctx context.Context, arg SearchUserByNameOrPhoneParams) ([]SearchUserByNameOrPhoneRow, error) { + rows, err := q.db.Query(ctx, SearchUserByNameOrPhone, arg.Column1, arg.Role, arg.CompanyID) if err != nil { return nil, err } diff --git a/gen/db/wallet.sql.go b/gen/db/wallet.sql.go index b3637f8..64c3359 100644 --- a/gen/db/wallet.sql.go +++ b/gen/db/wallet.sql.go @@ -14,33 +14,25 @@ import ( const CreateCustomerWallet = `-- name: CreateCustomerWallet :one INSERT INTO customer_wallets ( customer_id, - company_id, regular_wallet_id, static_wallet_id ) -VALUES ($1, $2, $3, $4) -RETURNING id, customer_id, company_id, regular_wallet_id, static_wallet_id, created_at, updated_at +VALUES ($1, $2, $3) +RETURNING id, customer_id, regular_wallet_id, static_wallet_id, created_at, updated_at ` type CreateCustomerWalletParams struct { CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularWalletID int64 `json:"regular_wallet_id"` StaticWalletID int64 `json:"static_wallet_id"` } func (q *Queries) CreateCustomerWallet(ctx context.Context, arg CreateCustomerWalletParams) (CustomerWallet, error) { - row := q.db.QueryRow(ctx, CreateCustomerWallet, - arg.CustomerID, - arg.CompanyID, - arg.RegularWalletID, - arg.StaticWalletID, - ) + row := q.db.QueryRow(ctx, CreateCustomerWallet, arg.CustomerID, arg.RegularWalletID, arg.StaticWalletID) var i CustomerWallet err := row.Scan( &i.ID, &i.CustomerID, - &i.CompanyID, &i.RegularWalletID, &i.StaticWalletID, &i.CreatedAt, @@ -190,7 +182,6 @@ func (q *Queries) GetAllWallets(ctx context.Context) ([]Wallet, error) { const GetCustomerWallet = `-- name: GetCustomerWallet :one SELECT cw.id, cw.customer_id, - cw.company_id, rw.id AS regular_id, rw.balance AS regular_balance, sw.id AS static_id, @@ -202,18 +193,11 @@ FROM customer_wallets cw JOIN wallets rw ON cw.regular_wallet_id = rw.id JOIN wallets sw ON cw.static_wallet_id = sw.id WHERE cw.customer_id = $1 - AND cw.company_id = $2 ` -type GetCustomerWalletParams struct { - CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` -} - type GetCustomerWalletRow struct { ID int64 `json:"id"` CustomerID int64 `json:"customer_id"` - CompanyID int64 `json:"company_id"` RegularID int64 `json:"regular_id"` RegularBalance int64 `json:"regular_balance"` StaticID int64 `json:"static_id"` @@ -223,13 +207,12 @@ type GetCustomerWalletRow struct { CreatedAt pgtype.Timestamp `json:"created_at"` } -func (q *Queries) GetCustomerWallet(ctx context.Context, arg GetCustomerWalletParams) (GetCustomerWalletRow, error) { - row := q.db.QueryRow(ctx, GetCustomerWallet, arg.CustomerID, arg.CompanyID) +func (q *Queries) GetCustomerWallet(ctx context.Context, customerID int64) (GetCustomerWalletRow, error) { + row := q.db.QueryRow(ctx, GetCustomerWallet, customerID) var i GetCustomerWalletRow err := row.Scan( &i.ID, &i.CustomerID, - &i.CompanyID, &i.RegularID, &i.RegularBalance, &i.StaticID, diff --git a/internal/repository/user.go b/internal/repository/user.go index df82a40..7405542 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -202,11 +202,28 @@ func (s *Store) GetCashiersByBranch(ctx context.Context, branchID int64) ([]doma return userList, nil } -func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) { - users, err := s.queries.SearchUserByNameOrPhone(ctx, pgtype.Text{ - String: searchString, - Valid: true, - }) +func (s *Store) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) { + + query := dbgen.SearchUserByNameOrPhoneParams{ + Column1: pgtype.Text{ + String: searchString, + Valid: true, + }, + CompanyID: pgtype.Int8{ + Int64: companyID.Value, + Valid: companyID.Valid, + }, + } + + if role != nil { + + query.Role = pgtype.Text{ + String: string(*role), + Valid: true, + } + } + + users, err := s.queries.SearchUserByNameOrPhone(ctx, query) if err != nil { return nil, err } diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go index e61fb74..54fd077 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -114,11 +114,8 @@ func (s *Store) GetWalletsByUser(ctx context.Context, userID int64) ([]domain.Wa return result, nil } -func (s *Store) GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) { - customerWallet, err := s.queries.GetCustomerWallet(ctx, dbgen.GetCustomerWalletParams{ - CustomerID: customerID, - CompanyID: companyID, - }) +func (s *Store) GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) { + customerWallet, err := s.queries.GetCustomerWallet(ctx, customerID) if err != nil { return domain.GetCustomerWallet{}, err diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 8f98433..4e3f9bf 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -23,6 +23,7 @@ var ( ErrNoEventsAvailable = errors.New("Not enough events available with the given filters") ErrGenerateRandomOutcome = errors.New("Failed to generate any random outcome for events") ErrOutcomesNotCompleted = errors.New("Some bet outcomes are still pending") + ErrEventHasBeenRemoved = errors.New("Event has been removed") ) type Service struct { @@ -75,7 +76,7 @@ func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketI event, err := s.eventSvc.GetUpcomingEventByID(ctx, eventIDStr) if err != nil { - return domain.CreateBetOutcome{}, err + return domain.CreateBetOutcome{}, ErrEventHasBeenRemoved } currentTime := time.Now() diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index a2c4016..36f3a8a 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -98,7 +98,6 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { s.logger.Error("Error while inserting ice hockey odd") errs = append(errs, err) } - } // result := oddsData.Results[0] diff --git a/internal/services/user/port.go b/internal/services/user/port.go index f6adec0..6a09597 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -21,7 +21,7 @@ type UserStore interface { CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) GetUserByEmail(ctx context.Context, email string) (domain.User, error) GetUserByPhone(ctx context.Context, phoneNum string) (domain.User, error) - SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) + SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) UpdatePassword(ctx context.Context, identifier string, password []byte, usedOtpId int64) error // identifier verified email or phone } type SmsGateway interface { diff --git a/internal/services/user/user.go b/internal/services/user/user.go index a9d303e..6529c16 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -6,9 +6,9 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" ) -func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string) ([]domain.User, error) { +func (s *Service) SearchUserByNameOrPhone(ctx context.Context, searchString string, role *domain.Role, companyID domain.ValidInt64) ([]domain.User, error) { // Search user - return s.userStore.SearchUserByNameOrPhone(ctx, searchString) + return s.userStore.SearchUserByNameOrPhone(ctx, searchString, role, companyID) } func (s *Service) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error { diff --git a/internal/services/wallet/port.go b/internal/services/wallet/port.go index 9271039..9c3fcb9 100644 --- a/internal/services/wallet/port.go +++ b/internal/services/wallet/port.go @@ -12,7 +12,7 @@ type WalletStore interface { GetWalletByID(ctx context.Context, id int64) (domain.Wallet, error) GetAllWallets(ctx context.Context) ([]domain.Wallet, error) GetWalletsByUser(ctx context.Context, id int64) ([]domain.Wallet, error) - GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) + GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error) UpdateBalance(ctx context.Context, id int64, balance domain.Currency) error UpdateWalletActive(ctx context.Context, id int64, isActive bool) error diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index 4749af2..feb29d0 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -56,8 +56,8 @@ func (s *Service) GetWalletsByUser(ctx context.Context, id int64) ([]domain.Wall return s.walletStore.GetWalletsByUser(ctx, id) } -func (s *Service) GetCustomerWallet(ctx context.Context, customerID int64, companyID int64) (domain.GetCustomerWallet, error) { - return s.walletStore.GetCustomerWallet(ctx, customerID, companyID) +func (s *Service) GetCustomerWallet(ctx context.Context, customerID int64) (domain.GetCustomerWallet, error) { + return s.walletStore.GetCustomerWallet(ctx, customerID) } func (s *Service) GetAllBranchWallets(ctx context.Context) ([]domain.BranchWallet, error) { diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index da85139..b01fbd3 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -6,6 +6,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" ) @@ -42,6 +43,10 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { if err != nil { h.logger.Error("PlaceBet failed", "error", err) + switch err { + case bet.ErrEventHasBeenRemoved, bet.ErrEventHasNotEnded, bet.ErrRawOddInvalid, wallet.ErrBalanceInsufficient: + return fiber.NewError(fiber.StatusBadGateway, err.Error()) + } return fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") } @@ -180,7 +185,7 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { bet, err := h.betSvc.GetBetByID(c.Context(), id) if err != nil { - // TODO: handle all the errors types + // TODO: handle all the errors types h.logger.Error("Failed to get bet by ID", "betID", id, "error", err) return fiber.NewError(fiber.StatusNotFound, "Failed to retrieve bet") } diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 4d6efcf..a0121f9 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -381,7 +381,8 @@ func getMedium(email, phoneNumber string) (domain.OtpMedium, error) { } type SearchUserByNameOrPhoneReq struct { - SearchString string + SearchString string `json:"query"` + Role *domain.Role `json:"role,omitempty"` } // SearchUserByNameOrPhone godoc @@ -409,7 +410,8 @@ func (h *Handler) SearchUserByNameOrPhone(c *fiber.Ctx) error { response.WriteJSON(c, fiber.StatusBadRequest, "Invalid request", valErrs, nil) return nil } - users, err := h.userSvc.SearchUserByNameOrPhone(c.Context(), req.SearchString) + companyID := c.Locals("company_id").(domain.ValidInt64) + users, err := h.userSvc.SearchUserByNameOrPhone(c.Context(), req.SearchString, req.Role, companyID) if err != nil { h.logger.Error("SearchUserByNameOrPhone failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{