diff --git a/cmd/main.go b/cmd/main.go index b57aacc..ac6ca85 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,17 +5,20 @@ import ( "log/slog" "os" + "github.com/go-playground/validator/v10" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" - "github.com/go-playground/validator/v10" ) // @title FortuneBet API @@ -32,32 +35,47 @@ import ( // @name Authorization // @BasePath / func main() { + // Load config cfg, err := config.NewConfig() if err != nil { - slog.Error(err.Error()) + slog.Error("❌ Config error:", "err", err) os.Exit(1) } + + // Connect to database db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print("db", err) + fmt.Println("❌ Database error:", err) os.Exit(1) } + + // Init core services logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) + + // Auth and user services authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) mockSms := mocksms.NewMockSMS() - mockemail := mockemail.NewMockEmail() - userSvc := user.NewService(store, store, mockSms, mockemail) + mockEmail := mockemail.NewMockEmail() + userSvc := user.NewService(store, store, mockSms, mockEmail) + + // 🎯 Event & Odds fetching services + eventSvc := event.New(cfg.Bet365Token, store) + oddsSvc := odds.New(cfg.Bet365Token, store) + + // 🕒 Start scheduled cron jobs + httpserver.StartDataFetchingCrons(eventSvc, oddsSvc) + + // 🚀 Start HTTP server app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, - }, userSvc, - ) + }, userSvc) + logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { logger.Error("Failed to start server", "error", err) os.Exit(1) } - } diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 489466f..180201e 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -72,3 +72,8 @@ DROP TABLE IF EXISTS ussd_account; DROP TYPE IF EXISTS ua_pin_status; DROP TYPE IF EXISTS ua_status; DROP TYPE IF EXISTS ua_registaration_type; + + +DROP TABLE IF EXISTS odds; +DROP TABLE IF EXISTS events; + diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index a4e3bd0..6d965b4 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -58,4 +58,47 @@ INSERT INTO users ( CURRENT_TIMESTAMP, NULL, FALSE -); \ No newline at end of file +); + +--------------------------------------------------Bet365 Data Fetching + Event Managment------------------------------------------------ +CREATE TABLE events ( + id TEXT PRIMARY KEY, + sport_id TEXT, + match_name TEXT, + home_team TEXT, + away_team TEXT, + home_team_id TEXT, + away_team_id TEXT, + home_kit_image TEXT, + away_kit_image TEXT, + league_id TEXT, + league_name TEXT, + league_cc TEXT, + start_time TIMESTAMP, + score TEXT, + match_minute INT, + timer_status TEXT, + added_time INT, + match_period INT, + is_live BOOLEAN, + status TEXT, + fetched_at TIMESTAMP DEFAULT now() +); + + +CREATE TABLE odds ( + id SERIAL PRIMARY KEY, + event_id TEXT, -- Parsed from "FI" (Bet365 Event ID). Nullable in case of failures. + market_type TEXT NOT NULL, -- E.g., 'asian_handicap', 'goal_line', 'both_teams_to_score' + header TEXT, -- E.g., '1', '2', 'Over', 'Under', 'Draw', 'Yes', 'No' + name TEXT, -- Bet name like "2.5", "Over 2.5 & Yes", etc. + odds_value DOUBLE PRECISION, -- The numeric odds (e.g., 1.920) + handicap TEXT, -- Handicap value like "-0.5", "0.0, +0.5" + section TEXT NOT NULL, -- Odds section: 'asian_lines', 'goals', etc. + category TEXT, -- Market category (e.g., 'sp') + market_id TEXT, -- Market ID from the API (e.g., "938", "50138") + fetched_at TIMESTAMP DEFAULT now(), -- When this record was fetched + source TEXT DEFAULT 'b365api', -- Source identifier + is_active BOOLEAN DEFAULT true, -- Optional deactivation flag + raw_event_id TEXT -- Original/failed event ID if event_id is nil or invalid +); diff --git a/db/query/events.sql b/db/query/events.sql new file mode 100644 index 0000000..dddec63 --- /dev/null +++ b/db/query/events.sql @@ -0,0 +1,38 @@ +-- name: InsertEvent :exec +INSERT INTO events ( + id, sport_id, match_name, home_team, away_team, + home_team_id, away_team_id, home_kit_image, away_kit_image, + league_id, league_name, league_cc, start_time, score, + match_minute, timer_status, added_time, match_period, + is_live, status +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20 +) +ON CONFLICT (id) DO UPDATE SET + sport_id = EXCLUDED.sport_id, + match_name = EXCLUDED.match_name, + home_team = EXCLUDED.home_team, + away_team = EXCLUDED.away_team, + home_team_id = EXCLUDED.home_team_id, + away_team_id = EXCLUDED.away_team_id, + home_kit_image = EXCLUDED.home_kit_image, + away_kit_image = EXCLUDED.away_kit_image, + league_id = EXCLUDED.league_id, + league_name = EXCLUDED.league_name, + league_cc = EXCLUDED.league_cc, + start_time = EXCLUDED.start_time, + score = EXCLUDED.score, + match_minute = EXCLUDED.match_minute, + timer_status = EXCLUDED.timer_status, + added_time = EXCLUDED.added_time, + match_period = EXCLUDED.match_period, + is_live = EXCLUDED.is_live, + status = EXCLUDED.status, + fetched_at = now(); + +-- name: ListLiveEvents :many +SELECT id FROM events WHERE is_live = true; \ No newline at end of file diff --git a/db/query/odds.sql b/db/query/odds.sql new file mode 100644 index 0000000..b041a85 --- /dev/null +++ b/db/query/odds.sql @@ -0,0 +1,14 @@ + +-- name: InsertNonLiveOdd :exec +INSERT INTO odds ( + event_id, market_type, header, name, odds_value, handicap, + section, category, market_id, is_active, source, fetched_at, raw_event_id +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, true, 'b365api', now(), $10 +); + + +-- name: GetUpcomingEventIDs :many +SELECT id FROM events +WHERE is_live = false; diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go index 27fb891..c826c36 100644 --- a/gen/db/auth.sql.go +++ b/gen/db/auth.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: auth.sql package dbgen diff --git a/gen/db/db.go b/gen/db/db.go index fe4ae38..136f20a 100644 --- a/gen/db/db.go +++ b/gen/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 package dbgen diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go new file mode 100644 index 0000000..e833d0e --- /dev/null +++ b/gen/db/events.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: events.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const InsertEvent = `-- name: InsertEvent :exec +INSERT INTO events ( + id, sport_id, match_name, home_team, away_team, + home_team_id, away_team_id, home_kit_image, away_kit_image, + league_id, league_name, league_cc, start_time, score, + match_minute, timer_status, added_time, match_period, + is_live, status +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20 +) +ON CONFLICT (id) DO UPDATE SET + sport_id = EXCLUDED.sport_id, + match_name = EXCLUDED.match_name, + home_team = EXCLUDED.home_team, + away_team = EXCLUDED.away_team, + home_team_id = EXCLUDED.home_team_id, + away_team_id = EXCLUDED.away_team_id, + home_kit_image = EXCLUDED.home_kit_image, + away_kit_image = EXCLUDED.away_kit_image, + league_id = EXCLUDED.league_id, + league_name = EXCLUDED.league_name, + league_cc = EXCLUDED.league_cc, + start_time = EXCLUDED.start_time, + score = EXCLUDED.score, + match_minute = EXCLUDED.match_minute, + timer_status = EXCLUDED.timer_status, + added_time = EXCLUDED.added_time, + match_period = EXCLUDED.match_period, + is_live = EXCLUDED.is_live, + status = EXCLUDED.status, + fetched_at = now() +` + +type InsertEventParams struct { + ID string + SportID pgtype.Text + MatchName pgtype.Text + HomeTeam pgtype.Text + AwayTeam pgtype.Text + HomeTeamID pgtype.Text + AwayTeamID pgtype.Text + HomeKitImage pgtype.Text + AwayKitImage pgtype.Text + LeagueID pgtype.Text + LeagueName pgtype.Text + LeagueCc pgtype.Text + StartTime pgtype.Timestamp + Score pgtype.Text + MatchMinute pgtype.Int4 + TimerStatus pgtype.Text + AddedTime pgtype.Int4 + MatchPeriod pgtype.Int4 + IsLive pgtype.Bool + Status pgtype.Text +} + +func (q *Queries) InsertEvent(ctx context.Context, arg InsertEventParams) error { + _, err := q.db.Exec(ctx, InsertEvent, + arg.ID, + arg.SportID, + arg.MatchName, + arg.HomeTeam, + arg.AwayTeam, + arg.HomeTeamID, + arg.AwayTeamID, + arg.HomeKitImage, + arg.AwayKitImage, + arg.LeagueID, + arg.LeagueName, + arg.LeagueCc, + arg.StartTime, + arg.Score, + arg.MatchMinute, + arg.TimerStatus, + arg.AddedTime, + arg.MatchPeriod, + arg.IsLive, + arg.Status, + ) + return err +} + +const ListLiveEvents = `-- name: ListLiveEvents :many +SELECT id FROM events WHERE is_live = true +` + +func (q *Queries) ListLiveEvents(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, ListLiveEvents) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/models.go b/gen/db/models.go index a1465c2..24be0bd 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 package dbgen @@ -8,6 +8,47 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Event struct { + ID string + SportID pgtype.Text + MatchName pgtype.Text + HomeTeam pgtype.Text + AwayTeam pgtype.Text + HomeTeamID pgtype.Text + AwayTeamID pgtype.Text + HomeKitImage pgtype.Text + AwayKitImage pgtype.Text + LeagueID pgtype.Text + LeagueName pgtype.Text + LeagueCc pgtype.Text + StartTime pgtype.Timestamp + Score pgtype.Text + MatchMinute pgtype.Int4 + TimerStatus pgtype.Text + AddedTime pgtype.Int4 + MatchPeriod pgtype.Int4 + IsLive pgtype.Bool + Status pgtype.Text + FetchedAt pgtype.Timestamp +} + +type Odd struct { + ID int32 + EventID pgtype.Text + MarketType string + Header pgtype.Text + Name pgtype.Text + OddsValue pgtype.Float8 + Handicap pgtype.Text + Section string + Category pgtype.Text + MarketID pgtype.Text + FetchedAt pgtype.Timestamp + Source pgtype.Text + IsActive pgtype.Bool + RawEventID pgtype.Text +} + type Otp struct { ID int64 SentTo string diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go new file mode 100644 index 0000000..fe33a5e --- /dev/null +++ b/gen/db/odds.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: odds.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetUpcomingEventIDs = `-- name: GetUpcomingEventIDs :many +SELECT id FROM events +WHERE is_live = false +` + +func (q *Queries) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, GetUpcomingEventIDs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const InsertNonLiveOdd = `-- name: InsertNonLiveOdd :exec +INSERT INTO odds ( + event_id, market_type, header, name, odds_value, handicap, + section, category, market_id, is_active, source, fetched_at, raw_event_id +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, true, 'b365api', now(), $10 +) +` + +type InsertNonLiveOddParams struct { + EventID pgtype.Text + MarketType string + Header pgtype.Text + Name pgtype.Text + OddsValue pgtype.Float8 + Handicap pgtype.Text + Section string + Category pgtype.Text + MarketID pgtype.Text + RawEventID pgtype.Text +} + +func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error { + _, err := q.db.Exec(ctx, InsertNonLiveOdd, + arg.EventID, + arg.MarketType, + arg.Header, + arg.Name, + arg.OddsValue, + arg.Handicap, + arg.Section, + arg.Category, + arg.MarketID, + arg.RawEventID, + ) + return err +} diff --git a/gen/db/otp.sql.go b/gen/db/otp.sql.go index 619bf92..e0b9806 100644 --- a/gen/db/otp.sql.go +++ b/gen/db/otp.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: otp.sql package dbgen diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 88ee397..39f0a5c 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: user.sql package dbgen diff --git a/go.mod b/go.mod index 2fb3275..02a28b2 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index c86e5af..1cd6ef9 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= diff --git a/internal/config/config.go b/internal/config/config.go index 229bd47..98a6a12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ var ( ErrLogLevel = errors.New("log level not set") ErrInvalidLevel = errors.New("invalid log level") ErrInvalidEnv = errors.New("env not set or invalid") + ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") ) type Config struct { @@ -29,6 +30,7 @@ type Config struct { JwtKey string LogLevel slog.Level Env string + Bet365Token string } func NewConfig() (*Config, error) { @@ -89,5 +91,10 @@ func (c *Config) loadEnv() error { return ErrInvalidLevel } c.LogLevel = lvl + betToken := os.Getenv("BET365_TOKEN") + if betToken == "" { + return ErrMissingBetToken + } + c.Bet365Token = betToken return nil } diff --git a/internal/domain/event.go b/internal/domain/event.go new file mode 100644 index 0000000..fb7edf4 --- /dev/null +++ b/internal/domain/event.go @@ -0,0 +1,23 @@ +package domain +type Event struct { + ID string + SportID string + MatchName string + HomeTeam string + AwayTeam string + HomeTeamID string + AwayTeamID string + HomeKitImage string + AwayKitImage string + LeagueID string + LeagueName string + LeagueCC string + StartTime string + Score string + MatchMinute int + TimerStatus string + AddedTime int + MatchPeriod int + IsLive bool + Status string +} \ No newline at end of file diff --git a/internal/domain/odds.go b/internal/domain/odds.go new file mode 100644 index 0000000..0af5dbd --- /dev/null +++ b/internal/domain/odds.go @@ -0,0 +1,14 @@ +package domain + +type OddsRecord struct { + EventID string + MarketType string + Header string + Name string + OddsValue float64 + Handicap string + Section string + Category string + MarketID string + RawEventID string +} diff --git a/internal/repository/event.go b/internal/repository/event.go new file mode 100644 index 0000000..1949a44 --- /dev/null +++ b/internal/repository/event.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "time" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) SaveEvent(ctx context.Context, e domain.Event) error { + parsedTime, err := time.Parse(time.RFC3339, e.StartTime) + if err != nil { + return err + } + + return s.queries.InsertEvent(ctx, dbgen.InsertEventParams{ + ID: e.ID, + SportID: pgtype.Text{String: e.SportID, Valid: true}, + MatchName: pgtype.Text{String: e.MatchName, Valid: true}, + HomeTeam: pgtype.Text{String: e.HomeTeam, Valid: true}, + AwayTeam: pgtype.Text{String: e.AwayTeam, Valid: true}, + HomeTeamID: pgtype.Text{String: e.HomeTeamID, Valid: true}, + AwayTeamID: pgtype.Text{String: e.AwayTeamID, Valid: true}, + HomeKitImage: pgtype.Text{String: e.HomeKitImage, Valid: true}, + AwayKitImage: pgtype.Text{String: e.AwayKitImage, Valid: true}, + LeagueID: pgtype.Text{String: e.LeagueID, Valid: true}, + LeagueName: pgtype.Text{String: e.LeagueName, Valid: true}, + LeagueCc: pgtype.Text{String: e.LeagueCC, Valid: true}, + StartTime: pgtype.Timestamp{Time: parsedTime, Valid: true}, + Score: pgtype.Text{String: e.Score, Valid: true}, + MatchMinute: pgtype.Int4{Int32: int32(e.MatchMinute), Valid: true}, + TimerStatus: pgtype.Text{String: e.TimerStatus, Valid: true}, + AddedTime: pgtype.Int4{Int32: int32(e.AddedTime), Valid: true}, + MatchPeriod: pgtype.Int4{Int32: int32(e.MatchPeriod), Valid: true}, + IsLive: pgtype.Bool{Bool: e.IsLive, Valid: true}, + Status: pgtype.Text{String: e.Status, Valid: true}, + }) +} + +func (s *Store) GetLiveEventIDs(ctx context.Context) ([]string, error) { + return s.queries.ListLiveEvents(ctx) +} diff --git a/internal/repository/odds.go b/internal/repository/odds.go new file mode 100644 index 0000000..c2ea64f --- /dev/null +++ b/internal/repository/odds.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "fmt" + + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + +func (s *Store) SaveNonLiveOdd(ctx context.Context, o domain.OddsRecord) error { + params := dbgen.InsertNonLiveOddParams{ + EventID: pgtype.Text{ + String: o.EventID, + Valid: o.EventID != "", + }, + MarketType: o.MarketType, + Header: pgtype.Text{ + String: o.Header, + Valid: o.Header != "", + }, + Name: pgtype.Text{ + String: o.Name, + Valid: o.Name != "", + }, + OddsValue: pgtype.Float8{ + Float64: o.OddsValue, + Valid: true, + }, + Handicap: pgtype.Text{ + String: o.Handicap, + Valid: o.Handicap != "", + }, + Section: o.Section, + Category: pgtype.Text{ + String: o.Category, + Valid: o.Category != "", + }, + MarketID: pgtype.Text{ + String: o.MarketID, + Valid: o.MarketID != "", + }, + RawEventID: pgtype.Text{ + String: o.RawEventID, + Valid: o.RawEventID != "", + }, + } + + err := s.queries.InsertNonLiveOdd(ctx, params) + + if err != nil { + fmt.Printf("❌ Failed to insert odd: event_id=%s | market=%s | odds=%.3f | error=%v\n", + o.EventID, o.MarketType, o.OddsValue, err) + } else { + fmt.Printf("✅ Stored: event_id=%s | market=%s | odds=%.3f\n", o.EventID, o.MarketType, o.OddsValue) + } + + return err +} + +func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { + return s.queries.GetUpcomingEventIDs(ctx) +} diff --git a/internal/services/event/port.go b/internal/services/event/port.go new file mode 100644 index 0000000..b500ca4 --- /dev/null +++ b/internal/services/event/port.go @@ -0,0 +1,8 @@ +package event + +import "context" + +type Service interface { + FetchLiveEvents(ctx context.Context) error + FetchUpcomingEvents(ctx context.Context) error +} diff --git a/internal/services/event/service.go b/internal/services/event/service.go new file mode 100644 index 0000000..3d54d7a --- /dev/null +++ b/internal/services/event/service.go @@ -0,0 +1,173 @@ +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" +) + +type service struct { + token string + store *repository.Store +} + +func New(token string, store *repository.Store) Service { + return &service{ + token: token, + store: store, + } +} + +func (s *service) FetchLiveEvents(ctx context.Context) error { + sportIDs := []int{1, 13, 78, 18, 91, 16, 17, 14, 12, 3, 2, 4, 83, 15, 92, 94, 8, 19, 36, 66, 9, 75, 90, 95, 110, 107, 151, 162, 148} + + var wg sync.WaitGroup + + for _, sportID := range sportIDs { + wg.Add(1) + go func(sportID int) { + defer wg.Done() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/inplay?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed request for sport_id=%d: %v\n", sportID, err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var data struct { + Success int `json:"success"` + Results [][]map[string]interface{} `json:"results"` + } + if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + fmt.Printf(" Decode failed for sport_id=%d\nRaw: %s\n", sportID, string(body)) + return + } + + for _, group := range data.Results { + for _, ev := range group { + if getString(ev["type"]) != "EV" { + continue + } + + event := domain.Event{ + ID: getString(ev["ID"]), + SportID: fmt.Sprintf("%d", sportID), + MatchName: getString(ev["NA"]), + Score: getString(ev["SS"]), + MatchMinute: getInt(ev["TM"]), + TimerStatus: getString(ev["TT"]), + HomeTeamID: getString(ev["HT"]), + AwayTeamID: getString(ev["AT"]), + HomeKitImage: getString(ev["K1"]), + AwayKitImage: getString(ev["K2"]), + LeagueName: getString(ev["CT"]), + LeagueID: getString(ev["C2"]), + LeagueCC: getString(ev["CB"]), + StartTime: time.Now().UTC().Format(time.RFC3339), + IsLive: true, + Status: "live", + MatchPeriod: getInt(ev["MD"]), + AddedTime: getInt(ev["TA"]), + } + + if err := s.store.SaveEvent(ctx, event); err != nil { + fmt.Printf("Could not store live event [id=%s]: %v\n", event.ID, err) + } + } + } + }(sportID) + } + + wg.Wait() + fmt.Println("All live events fetched and stored.") + return nil +} + +func (s *service) FetchUpcomingEvents(ctx context.Context) error { + sportIDs := []int{1, 13, 78, 18, 91, 16, 17, 14, 12, 3, 2, 4, 83, 15, 92, 94, 8, 19, 36, 66, 9, 75, 90, 95, 110, 107, 151, 162, 148} + + var wg sync.WaitGroup + + for _, sportID := range sportIDs { + wg.Add(1) + go func(sportID int) { + defer wg.Done() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed request for upcoming sport_id=%d: %v\n", sportID, err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var data struct { + Success int `json:"success"` + Results [][]map[string]interface{} `json:"results"` + } + if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + fmt.Printf(" Decode failed for upcoming sport_id=%d\nRaw: %s\n", sportID, string(body)) + return + } + + for _, group := range data.Results { + for _, ev := range group { + if getString(ev["type"]) != "EV" { + continue + } + + event := domain.Event{ + ID: getString(ev["ID"]), + SportID: fmt.Sprintf("%d", sportID), + MatchName: getString(ev["NA"]), + HomeTeamID: getString(ev["HT"]), + AwayTeamID: getString(ev["AT"]), + HomeKitImage: getString(ev["K1"]), + AwayKitImage: getString(ev["K2"]), + LeagueID: getString(ev["C2"]), + LeagueName: getString(ev["CT"]), + LeagueCC: getString(ev["CB"]), + StartTime: time.Now().UTC().Format(time.RFC3339), + IsLive: false, + Status: "upcoming", + } + + if err := s.store.SaveEvent(ctx, event); err != nil { + fmt.Printf(" Could not store upcoming event [id=%s]: %v\n", event.ID, err) + } + } + } + }(sportID) + } + + wg.Wait() + fmt.Println(" All upcoming events fetched and stored.") + return nil +} + +func getString(v interface{}) string { + if str, ok := v.(string); ok { + return str + } + return "" +} + +func getInt(v interface{}) int { + if f, ok := v.(float64); ok { + return int(f) + } + return 0 +} diff --git a/internal/services/odds/port.go b/internal/services/odds/port.go new file mode 100644 index 0000000..d95367e --- /dev/null +++ b/internal/services/odds/port.go @@ -0,0 +1,8 @@ +package odds + +import "context" + +type Service interface { + FetchNonLiveOdds(ctx context.Context) error + +} diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go new file mode 100644 index 0000000..090add4 --- /dev/null +++ b/internal/services/odds/service.go @@ -0,0 +1,152 @@ +package odds + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" +) + +type ServiceImpl struct { + token string + store *repository.Store +} + +func New(token string, store *repository.Store) *ServiceImpl { + return &ServiceImpl{token: token, store: store} +} +func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { + eventIDs, err := s.store.GetUpcomingEventIDs(ctx) + if err != nil { + return fmt.Errorf("fetch upcoming event IDs: %w", err) + } + + type OddsMarket struct { + ID string `json:"id"` + Name string `json:"name"` + Odds []struct { + ID string `json:"id"` + Odds string `json:"odds"` + Header string `json:"header,omitempty"` + Name string `json:"name,omitempty"` + Handicap string `json:"handicap,omitempty"` + } `json:"odds"` + } + + type OddsSection struct { + UpdatedAt string `json:"updated_at"` + Sp map[string]OddsMarket `json:"sp"` + } + + type StructuredOddsResponse struct { + Success int `json:"success"` + Results []struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + AsianLines OddsSection `json:"asian_lines"` + Goals OddsSection `json:"goals"` + } `json:"results"` + } + + var wg sync.WaitGroup + sem := make(chan struct{}, 5) + + for _, eventID := range eventIDs { + if eventID == "" || len(eventID) < 5 { + continue + } + + wg.Add(1) + go func(originalID string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/odds?token=%s&event_id=%s", s.token, originalID) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed HTTP request for event_id=%s: %v\n", originalID, err) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Printf(" Failed to read response body for event_id=%s: %v\n", originalID, err) + return + } + + var data StructuredOddsResponse + if err := json.Unmarshal(body, &data); err != nil { + fmt.Printf(" JSON unmarshal failed for event_id=%s. Response: %s\n", originalID, string(body)) + return + } + + if data.Success != 1 || len(data.Results) == 0 { + fmt.Printf(" API response error or no results for event_id=%s\nBody: %s\n", originalID, string(body)) + return + } + + result := data.Results[0] + finalEventID := result.EventID + if finalEventID == "" { + finalEventID = result.FI + } + if finalEventID == "" { + fmt.Printf(" Skipping event_id=%s due to missing both event_id and FI\n", originalID) + return + } + + saveOdds := func(sectionName string, section OddsSection) { + for marketType, market := range section.Sp { + for _, odd := range market.Odds { + val, err := strconv.ParseFloat(odd.Odds, 64) + if err != nil { + fmt.Printf(" Skipping invalid odds for market=%s, event_id=%s\n", marketType, finalEventID) + continue + } + + record := domain.OddsRecord{ + EventID: finalEventID, + MarketType: marketType, + Header: odd.Header, + Name: odd.Name, + Handicap: odd.Handicap, + OddsValue: val, + Section: sectionName, + Category: market.ID, + MarketID: odd.ID, + RawEventID: originalID, + } + + fmt.Printf("🟡 Preparing to store: event_id=%s | market=%s | header=%s | name=%s | odds=%.3f\n", + finalEventID, marketType, odd.Header, odd.Name, val) + + err = s.store.SaveNonLiveOdd(ctx, record) + if err != nil { + fmt.Printf("❌ DB save error for market=%s, event_id=%s: %v\nRecord: %+v\n", marketType, finalEventID, err, record) + } else { + fmt.Printf("✅ Stored odd: event_id=%s | market=%s | odds=%.3f\n", finalEventID, marketType, val) + } + } + } + } + + + saveOdds("asian_lines", result.AsianLines) + saveOdds("goals", result.Goals) + + }(eventID) + } + + wg.Wait() + fmt.Println("✅ All non-live odds fetched and stored.") + return nil +} + diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go new file mode 100644 index 0000000..e895b6e --- /dev/null +++ b/internal/web_server/cron.go @@ -0,0 +1,54 @@ +package httpserver + +import ( + "context" + "log" + + eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/robfig/cron/v3" +) + +func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.Service) { + c := cron.New(cron.WithSeconds()) + + schedule := []struct { + spec string + task func() + }{ + { + spec: "0 0 * * * *", // Every 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: "*/5 * * * * *", // Every 5 seconds + task: func() { + if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + log.Printf(" FetchNonLiveOdds error: %v", err) + } + }, + }, + + } + + for _, job := range schedule { + 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 event and odds services") +}