event and odd data

This commit is contained in:
OneTap Technologies 2025-04-10 16:42:26 +03:00
parent e105716f50
commit 92250d61a8
24 changed files with 921 additions and 14 deletions

View File

@ -5,17 +5,20 @@ import (
"log/slog" "log/slog"
"os" "os"
"github.com/go-playground/validator/v10"
"github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/config"
customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger"
mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email"
mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user"
httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server"
jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt"
customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator"
"github.com/go-playground/validator/v10"
) )
// @title FortuneBet API // @title FortuneBet API
@ -32,32 +35,47 @@ import (
// @name Authorization // @name Authorization
// @BasePath / // @BasePath /
func main() { func main() {
// Load config
cfg, err := config.NewConfig() cfg, err := config.NewConfig()
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error("❌ Config error:", "err", err)
os.Exit(1) os.Exit(1)
} }
// Connect to database
db, _, err := repository.OpenDB(cfg.DbUrl) db, _, err := repository.OpenDB(cfg.DbUrl)
if err != nil { if err != nil {
fmt.Print("db", err) fmt.Println("❌ Database error:", err)
os.Exit(1) os.Exit(1)
} }
// Init core services
logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel)
store := repository.NewStore(db) store := repository.NewStore(db)
v := customvalidator.NewCustomValidator(validator.New()) v := customvalidator.NewCustomValidator(validator.New())
// Auth and user services
authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry)
mockSms := mocksms.NewMockSMS() mockSms := mocksms.NewMockSMS()
mockemail := mockemail.NewMockEmail() mockEmail := mockemail.NewMockEmail()
userSvc := user.NewService(store, store, mockSms, mockemail) 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{ app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{
JwtAccessKey: cfg.JwtKey, JwtAccessKey: cfg.JwtKey,
JwtAccessExpiry: cfg.AccessExpiry, JwtAccessExpiry: cfg.AccessExpiry,
}, userSvc, }, userSvc)
)
logger.Info("Starting server", "port", cfg.Port) logger.Info("Starting server", "port", cfg.Port)
if err := app.Run(); err != nil { if err := app.Run(); err != nil {
logger.Error("Failed to start server", "error", err) logger.Error("Failed to start server", "error", err)
os.Exit(1) os.Exit(1)
} }
} }

View File

@ -72,3 +72,8 @@ DROP TABLE IF EXISTS ussd_account;
DROP TYPE IF EXISTS ua_pin_status; DROP TYPE IF EXISTS ua_pin_status;
DROP TYPE IF EXISTS ua_status; DROP TYPE IF EXISTS ua_status;
DROP TYPE IF EXISTS ua_registaration_type; DROP TYPE IF EXISTS ua_registaration_type;
DROP TABLE IF EXISTS odds;
DROP TABLE IF EXISTS events;

View File

@ -59,3 +59,46 @@ INSERT INTO users (
NULL, NULL,
FALSE FALSE
); );
--------------------------------------------------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
);

38
db/query/events.sql Normal file
View File

@ -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;

14
db/query/odds.sql Normal file
View File

@ -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;

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.26.0 // sqlc v1.28.0
// source: auth.sql // source: auth.sql
package dbgen package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.26.0 // sqlc v1.28.0
package dbgen package dbgen

122
gen/db/events.sql.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.26.0 // sqlc v1.28.0
package dbgen package dbgen
@ -8,6 +8,47 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 { type Otp struct {
ID int64 ID int64
SentTo string SentTo string

76
gen/db/odds.sql.go Normal file
View File

@ -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
}

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.26.0 // sqlc v1.28.0
// source: otp.sql // source: otp.sql
package dbgen package dbgen

View File

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT. // Code generated by sqlc. DO NOT EDIT.
// versions: // versions:
// sqlc v1.26.0 // sqlc v1.28.0
// source: user.sql // source: user.sql
package dbgen package dbgen

1
go.mod
View File

@ -39,6 +39,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // 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/rogpeppe/go-internal v1.14.1 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect

2
go.sum
View File

@ -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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

View File

@ -19,6 +19,7 @@ var (
ErrLogLevel = errors.New("log level not set") ErrLogLevel = errors.New("log level not set")
ErrInvalidLevel = errors.New("invalid log level") ErrInvalidLevel = errors.New("invalid log level")
ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidEnv = errors.New("env not set or invalid")
ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env")
) )
type Config struct { type Config struct {
@ -29,6 +30,7 @@ type Config struct {
JwtKey string JwtKey string
LogLevel slog.Level LogLevel slog.Level
Env string Env string
Bet365Token string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -89,5 +91,10 @@ func (c *Config) loadEnv() error {
return ErrInvalidLevel return ErrInvalidLevel
} }
c.LogLevel = lvl c.LogLevel = lvl
betToken := os.Getenv("BET365_TOKEN")
if betToken == "" {
return ErrMissingBetToken
}
c.Bet365Token = betToken
return nil return nil
} }

23
internal/domain/event.go Normal file
View File

@ -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
}

14
internal/domain/odds.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1,8 @@
package event
import "context"
type Service interface {
FetchLiveEvents(ctx context.Context) error
FetchUpcomingEvents(ctx context.Context) error
}

View File

@ -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
}

View File

@ -0,0 +1,8 @@
package odds
import "context"
type Service interface {
FetchNonLiveOdds(ctx context.Context) error
}

View File

@ -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
}

View File

@ -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")
}