diff --git a/cmd/main.go b/cmd/main.go index 250a2b9..76353d7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,10 +1,13 @@ package main import ( + // "context" "fmt" "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" @@ -16,12 +19,13 @@ import ( notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" + "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/wallet" 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 @@ -40,43 +44,50 @@ import ( func main() { cfg, err := config.NewConfig() if err != nil { - slog.Error(err.Error()) + slog.Error("❌ Config error:", "err", err) os.Exit(1) } db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print("db", err) + fmt.Println("❌ Database error:", err) os.Exit(1) } + logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) mockSms := mocksms.NewMockSMS() - mockemail := mockemail.NewMockEmail() + mockEmail := mockemail.NewMockEmail() - userSvc := user.NewService(store, store, mockSms, mockemail) + userSvc := user.NewService(store, store, mockSms, mockEmail) + + eventSvc := event.New(cfg.Bet365Token, store) + oddsSvc := odds.New(cfg.Bet365Token, store) + + ticketSvc := ticket.NewService(store) betSvc := bet.NewService(store) walletSvc := wallet.NewService(store, store) transactionSvc := transaction.NewService(store) branchSvc := branch.NewService(store) - + notificationRepo := repository.NewNotificationRepository(store) notificationSvc := notificationservice.New(notificationRepo, logger, cfg) + + httpserver.StartDataFetchingCrons(eventSvc, oddsSvc) app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, - }, userSvc, ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, notificationSvc, - ) + }, userSvc, ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, notificationSvc, oddsSvc) + 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 d077f0b..35659ea 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -88,3 +88,8 @@ DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS otps; + + +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 830ad51..25ae2bc 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -176,6 +176,54 @@ CREATE TABLE IF NOT EXISTS branch_cashiers ( branch_id BIGINT NOT NULL, UNIQUE(user_id, branch_id) ); + +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, + fi TEXT, + raw_event_id TEXT, + market_type TEXT NOT NULL, + market_name TEXT, + market_category TEXT, + market_id TEXT, + header TEXT, + name TEXT, + handicap TEXT, + odds_value DOUBLE PRECISION, + section TEXT NOT NULL, + category TEXT, + raw_odds JSONB, + fetched_at TIMESTAMP DEFAULT now(), + source TEXT DEFAULT 'b365api', + is_active BOOLEAN DEFAULT true, + UNIQUE (event_id, market_id, header, name, handicap) +); + + + ALTER TABLE refresh_tokens ADD CONSTRAINT fk_refresh_tokens_users FOREIGN KEY (user_id) REFERENCES users(id); ALTER TABLE bets @@ -294,4 +342,6 @@ VALUES ( TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - ); \ No newline at end of file + ); + +--------------------------------------------------Bet365 Data Fetching + Event Managment------------------------------------------------ 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..07e1c99 --- /dev/null +++ b/db/query/odds.sql @@ -0,0 +1,59 @@ +-- name: InsertNonLiveOdd :exec +INSERT INTO odds ( + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + is_active, + source, + fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + true, 'b365api', now() +) +ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET + odds_value = EXCLUDED.odds_value, + raw_odds = EXCLUDED.raw_odds, + market_type = EXCLUDED.market_type, + market_name = EXCLUDED.market_name, + market_category = EXCLUDED.market_category, + fetched_at = now(), + is_active = true, + source = 'b365api', + fi = EXCLUDED.fi, + raw_event_id = EXCLUDED.raw_event_id; + + +-- name: GetPrematchOdds :many +SELECT + id, + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + fetched_at, + source, + is_active +FROM odds +WHERE event_id = $1 AND is_active = true AND source = 'b365api'; \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 71ba3a4..d569ff8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1246,6 +1246,53 @@ const docTemplate = `{ } } }, + "/prematch/odds/{event_id}": { + "get": { + "description": "Retrieve prematch odds for a specific event by event ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve prematch odds for an event", + "parameters": [ + { + "type": "string", + "description": "Event ID", + "name": "event_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Odd" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/search/branch": { "get": { "description": "Search branches by name or location", @@ -2348,6 +2395,66 @@ const docTemplate = `{ "BET_STATUS_ERROR" ] }, + "domain.Odd": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "fetched_at": { + "type": "string" + }, + "fi": { + "type": "string" + }, + "handicap": { + "type": "string" + }, + "header": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "market_category": { + "type": "string" + }, + "market_id": { + "type": "string" + }, + "market_name": { + "type": "string" + }, + "market_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "odds_value": { + "type": "number" + }, + "raw_event_id": { + "type": "string" + }, + "raw_odds": { + "type": "array", + "items": {} + }, + "section": { + "type": "string" + }, + "source": { + "type": "string" + } + } + }, "domain.PaymentOption": { "type": "integer", "enum": [ diff --git a/docs/swagger.json b/docs/swagger.json index 5e4b713..31f5a1d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1238,6 +1238,53 @@ } } }, + "/prematch/odds/{event_id}": { + "get": { + "description": "Retrieve prematch odds for a specific event by event ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve prematch odds for an event", + "parameters": [ + { + "type": "string", + "description": "Event ID", + "name": "event_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Odd" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/search/branch": { "get": { "description": "Search branches by name or location", @@ -2340,6 +2387,66 @@ "BET_STATUS_ERROR" ] }, + "domain.Odd": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "fetched_at": { + "type": "string" + }, + "fi": { + "type": "string" + }, + "handicap": { + "type": "string" + }, + "header": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "market_category": { + "type": "string" + }, + "market_id": { + "type": "string" + }, + "market_name": { + "type": "string" + }, + "market_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "odds_value": { + "type": "number" + }, + "raw_event_id": { + "type": "string" + }, + "raw_odds": { + "type": "array", + "items": {} + }, + "section": { + "type": "string" + }, + "source": { + "type": "string" + } + } + }, "domain.PaymentOption": { "type": "integer", "enum": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 604daf7..a6726d2 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -22,6 +22,46 @@ definitions: - BET_STATUS_WIN - BET_STATUS_LOSS - BET_STATUS_ERROR + domain.Odd: + properties: + category: + type: string + event_id: + type: string + fetched_at: + type: string + fi: + type: string + handicap: + type: string + header: + type: string + id: + type: integer + is_active: + type: boolean + market_category: + type: string + market_id: + type: string + market_name: + type: string + market_type: + type: string + name: + type: string + odds_value: + type: number + raw_event_id: + type: string + raw_odds: + items: {} + type: array + section: + type: string + source: + type: string + type: object domain.PaymentOption: enum: - 0 @@ -1495,6 +1535,37 @@ paths: summary: Create a operation tags: - branch + /prematch/odds/{event_id}: + get: + consumes: + - application/json + description: Retrieve prematch odds for a specific event by event ID + parameters: + - description: Event ID + in: path + name: event_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/domain.Odd' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Retrieve prematch odds for an event + tags: + - prematch /search/branch: get: consumes: 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 d41d796..31b7b3b 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -98,6 +98,30 @@ type CustomerWallet struct { UpdatedAt pgtype.Timestamp } +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 Notification struct { ID string RecipientID int64 @@ -115,6 +139,27 @@ type Notification struct { Metadata []byte } +type Odd struct { + ID int32 + EventID pgtype.Text + Fi pgtype.Text + RawEventID pgtype.Text + MarketType string + MarketName pgtype.Text + MarketCategory pgtype.Text + MarketID pgtype.Text + Header pgtype.Text + Name pgtype.Text + Handicap pgtype.Text + OddsValue pgtype.Float8 + Section string + Category pgtype.Text + RawOdds []byte + FetchedAt pgtype.Timestamp + Source pgtype.Text + IsActive pgtype.Bool +} + 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..003be80 --- /dev/null +++ b/gen/db/odds.sql.go @@ -0,0 +1,149 @@ +// 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 GetPrematchOdds = `-- name: GetPrematchOdds :many +SELECT + id, + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + fetched_at, + source, + is_active +FROM odds +WHERE event_id = $1 AND is_active = true AND source = 'b365api' +` + +func (q *Queries) GetPrematchOdds(ctx context.Context, eventID pgtype.Text) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPrematchOdds, eventID) + 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.RawEventID, + &i.MarketType, + &i.MarketName, + &i.MarketCategory, + &i.MarketID, + &i.Header, + &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 InsertNonLiveOdd = `-- name: InsertNonLiveOdd :exec +INSERT INTO odds ( + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + is_active, + source, + fetched_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + true, 'b365api', now() +) +ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET + odds_value = EXCLUDED.odds_value, + raw_odds = EXCLUDED.raw_odds, + market_type = EXCLUDED.market_type, + market_name = EXCLUDED.market_name, + market_category = EXCLUDED.market_category, + fetched_at = now(), + is_active = true, + source = 'b365api', + fi = EXCLUDED.fi, + raw_event_id = EXCLUDED.raw_event_id +` + +type InsertNonLiveOddParams struct { + EventID pgtype.Text + Fi pgtype.Text + RawEventID pgtype.Text + MarketType string + MarketName pgtype.Text + MarketCategory pgtype.Text + MarketID pgtype.Text + Header pgtype.Text + Name pgtype.Text + Handicap pgtype.Text + OddsValue pgtype.Float8 + Section string + Category pgtype.Text + RawOdds []byte +} + +func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error { + _, err := q.db.Exec(ctx, InsertNonLiveOdd, + arg.EventID, + arg.Fi, + arg.RawEventID, + arg.MarketType, + arg.MarketName, + arg.MarketCategory, + arg.MarketID, + arg.Header, + arg.Name, + arg.Handicap, + arg.OddsValue, + arg.Section, + arg.Category, + arg.RawOdds, + ) + return err +} diff --git a/go.mod b/go.mod index 440298d..2c2e549 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( 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/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 golang.org/x/crypto v0.36.0 diff --git a/go.sum b/go.sum index 6539eff..67b5c0c 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,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 84280b7..8cdf7e0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ var ( ErrInvalidLevel = errors.New("invalid log level") ErrInvalidEnv = errors.New("env not set or invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") + ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") ) type Config struct { @@ -34,6 +35,7 @@ type Config struct { AFRO_SMS_SENDER_NAME string AFRO_SMS_RECEIVER_PHONE_NUMBER string ADRO_SMS_HOST_URL string + Bet365Token string } func NewConfig() (*Config, error) { @@ -126,5 +128,10 @@ func (c *Config) loadEnv() error { c.ADRO_SMS_HOST_URL = "https://api.afrosms.com" } + 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 index 43e30fb..fb7edf4 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -1,8 +1,23 @@ package domain - -type Event struct {} - -type Outcome struct { - -} - +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..418b33d --- /dev/null +++ b/internal/domain/odds.go @@ -0,0 +1,45 @@ +package domain + +import ( + "encoding/json" + "time" + +) +type RawMessage interface{} // Change from json.RawMessage to interface{} + +type Market struct { + EventID string + FI string + MarketCategory string + MarketType string + MarketName string + MarketID string + UpdatedAt time.Time + Odds []json.RawMessage + + Header string + Name string + Handicap string + OddsVal float64 +} + +type Odd struct { + ID int64 `json:"id"` + EventID string `json:"event_id"` + Fi string `json:"fi"` + RawEventID string `json:"raw_event_id"` + MarketType string `json:"market_type"` + MarketName string `json:"market_name"` + MarketCategory string `json:"market_category"` + MarketID string `json:"market_id"` + Header string `json:"header"` + Name string `json:"name"` + Handicap string `json:"handicap"` + OddsValue float64 `json:"odds_value"` + Section string `json:"section"` + Category string `json:"category"` + RawOdds []RawMessage `json:"raw_odds"` + FetchedAt time.Time `json:"fetched_at"` + Source string `json:"source"` + IsActive bool `json:"is_active"` +} \ No newline at end of file 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..a21c729 --- /dev/null +++ b/internal/repository/odds.go @@ -0,0 +1,151 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "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) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { + if len(m.Odds) == 0 { + fmt.Printf(" Market has no odds: %s (%s)\n", m.MarketType, m.EventID) + return nil + } + + for _, raw := range m.Odds { + var item map[string]interface{} + if err := json.Unmarshal(raw, &item); err != nil { + fmt.Printf(" Invalid odd JSON for %s (%s): %v\n", m.MarketType, m.EventID, err) + continue + } + + header := getString(item["header"]) + name := getString(item["name"]) + handicap := getString(item["handicap"]) + oddsVal := getFloat(item["odds"]) + + rawOddsBytes, _ := json.Marshal(m.Odds) + + params := dbgen.InsertNonLiveOddParams{ + EventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""}, + Fi: pgtype.Text{String: m.FI, Valid: m.FI != ""}, + RawEventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""}, + MarketType: m.MarketType, + MarketName: pgtype.Text{String: m.MarketName, Valid: m.MarketName != ""}, + MarketCategory: pgtype.Text{String: m.MarketCategory, Valid: m.MarketCategory != ""}, + MarketID: pgtype.Text{String: m.MarketID, Valid: m.MarketID != ""}, + Header: pgtype.Text{String: header, Valid: header != ""}, + Name: pgtype.Text{String: name, Valid: name != ""}, + Handicap: pgtype.Text{String: handicap, Valid: handicap != ""}, + OddsValue: pgtype.Float8{Float64: oddsVal, Valid: oddsVal != 0}, + Section: m.MarketCategory, + Category: pgtype.Text{Valid: false}, + RawOdds: rawOddsBytes, + } + + err := s.queries.InsertNonLiveOdd(ctx, params) + if err != nil { + fmt.Printf(" Failed to insert odd for market %s (%s): %v\n", m.MarketType, m.EventID, err) + _ = writeFailedMarketLog(m, err) + continue + } + + fmt.Printf("Inserted odd: %s | type=%s | header=%s | name=%s\n", m.EventID, m.MarketType, header, name) + } + return nil +} + + +func writeFailedMarketLog(m domain.Market, err error) error { + logDir := "logs" + logFile := logDir + "/failed_markets.log" + + if mkErr := os.MkdirAll(logDir, 0755); mkErr != nil { + return mkErr + } + + f, fileErr := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if fileErr != nil { + return fileErr + } + defer f.Close() + + entry := struct { + Time string `json:"time"` + Error string `json:"error"` + Record domain.Market `json:"record"` + }{ + Time: time.Now().Format(time.RFC3339), + Error: err.Error(), + Record: m, + } + + jsonData, _ := json.MarshalIndent(entry, "", " ") + _, writeErr := f.WriteString(string(jsonData) + "\n\n") + return writeErr +} + +func getString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func getFloat(v interface{}) float64 { + if s, ok := v.(string); ok { + f, err := strconv.ParseFloat(s, 64) + if err == nil { + return f + } + } + return 0 +} + +func (s *Store) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { + eventIDParam := pgtype.Text{String: eventID, Valid: eventID != ""} + + odds, err := s.queries.GetPrematchOdds(ctx, eventIDParam) + if err != nil { + return nil, err + } + + domainOdds := make([]domain.Odd, len(odds)) + for i, odd := range odds { + domainOdds[i] = domain.Odd{ + ID: int64(odd.ID), // Cast int32 to int64 + EventID: odd.EventID.String, // Extract the String value + Fi: odd.Fi.String, // Extract the String value + RawEventID: odd.RawEventID.String, // Extract the String value + MarketType: odd.MarketType, // Direct assignment + MarketName: odd.MarketName.String, // Extract the String value + MarketCategory: odd.MarketCategory.String, // Extract the String value + MarketID: odd.MarketID.String, // Extract the String value + Header: odd.Header.String, // Extract the String value + Name: odd.Name.String, // Extract the String value + Handicap: odd.Handicap.String, // Extract the String value + OddsValue: odd.OddsValue.Float64, // Extract the Float64 value + Section: odd.Section, // Direct assignment + Category: odd.Category.String, // Extract the String value + RawOdds: func() []domain.RawMessage { + var rawOdds []domain.RawMessage + if err := json.Unmarshal(odd.RawOdds, &rawOdds); err != nil { + rawOdds = nil + } + return rawOdds + }(), + FetchedAt: odd.FetchedAt.Time, // Extract the Time value + Source: odd.Source.String, // Extract the String value + IsActive: odd.IsActive.Bool, // Extract the Bool value + } + } + + return domainOdds, nil +} \ No newline at end of file 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..d50a8af --- /dev/null +++ b/internal/services/odds/port.go @@ -0,0 +1,14 @@ +package odds + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type Service interface { + FetchNonLiveOdds(ctx context.Context) error + GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) + + +} diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go new file mode 100644 index 0000000..2f3245d --- /dev/null +++ b/internal/services/odds/service.go @@ -0,0 +1,165 @@ +package odds + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "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 { + fmt.Println("Starting FetchNonLiveOdds...") + + sportID := 1 + upcomingURL := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(upcomingURL) + if err != nil { + fmt.Printf("Failed to fetch upcoming: %v\n", err) + return err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var upcomingData struct { + Success int `json:"success"` + Results []struct { + ID string `json:"id"` + } `json:"results"` + } + if err := json.Unmarshal(body, &upcomingData); err != nil || upcomingData.Success != 1 { + fmt.Printf("Failed to decode upcoming response\nRaw: %s\n", string(body)) + return err + } + + for _, ev := range upcomingData.Results { + eventID := ev.ID + fmt.Printf("Fetching prematch odds for event_id=%s\n", eventID) + prematchURL := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%s", s.token, eventID) + oddsResp, err := http.Get(prematchURL) + if err != nil { + fmt.Printf(" Odds fetch failed for event_id=%s: %v\n", eventID, err) + continue + } + defer oddsResp.Body.Close() + + oddsBody, _ := io.ReadAll(oddsResp.Body) + fmt.Printf(" Raw odds response for event_id=%s: %.300s...\n", eventID, string(oddsBody)) + + var oddsData struct { + Success int `json:"success"` + Results []struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + } `json:"results"` + } + if err := json.Unmarshal(oddsBody, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { + fmt.Printf(" Failed odds decode for event_id=%s\nRaw: %s\n", eventID, string(oddsBody)) + continue + } + + result := oddsData.Results[0] + finalID := result.EventID + if finalID == "" { + finalID = result.FI + } + if finalID == "" { + fmt.Println(" Skipping event with missing final ID.") + continue + } + + fmt.Printf("🗂 Saving prematch odds for event_id=%s\n", finalID) + s.storeSection(ctx, finalID, result.FI, "main", result.Main) + fmt.Printf(" Finished storing prematch odds for event_id=%s\n", finalID) + } + + fmt.Println(" All prematch odds fetched and stored.") + return nil +} + +func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { + fmt.Printf(" Processing section '%s' for event_id=%s\n", sectionName, eventID) + if len(section.Sp) == 0 { + fmt.Printf(" No odds in section '%s' for event_id=%s\n", sectionName, eventID) + return + } + + updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) + updatedAt := time.Unix(updatedAtUnix, 0) + + for marketType, market := range section.Sp { + fmt.Printf(" Processing market: %s (%s)\n", marketType, market.ID) + if len(market.Odds) == 0 { + fmt.Printf(" Empty odds for marketType=%s in section=%s\n", marketType, sectionName) + continue + } + + marketRecord := domain.Market{ + EventID: eventID, + FI: fi, + MarketCategory: sectionName, + MarketType: marketType, + MarketName: market.Name, + MarketID: market.ID, + UpdatedAt: updatedAt, + Odds: market.Odds, + } + + fmt.Printf(" Saving market to DB: %s (%s)\n", marketType, market.ID) + err := s.store.SaveNonLiveMarket(ctx, marketRecord) + if err != nil { + fmt.Printf(" Save failed for market %s (%s): %v\n", marketType, eventID, err) + } else { + fmt.Printf(" Successfully stored market: %s (%s)\n", marketType, eventID) + } + } +} + + +type OddsMarket struct { + ID string `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"` +} + +func getString(v interface{}) string { + if str, ok := v.(string); ok { + return str + } + return "" +} + +func (s *ServiceImpl) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { + fmt.Printf("Retrieving prematch odds for event_id=%s\n", eventID) + + odds, err := s.store.GetPrematchOdds(ctx, eventID) + if err != nil { + fmt.Printf(" Failed to retrieve odds for event_id=%s: %v\n", eventID, err) + return nil, err + } + + fmt.Printf(" Retrieved %d odds entries for event_id=%s\n", len(odds), eventID) + return odds, nil +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 53cac25..81395c1 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -1,22 +1,24 @@ package httpserver import ( - "fmt" - "log/slog" + "fmt" + "log/slog" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" - jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" - customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" - "github.com/bytedance/sonic" - "github.com/gofiber/fiber/v2" + "github.com/bytedance/sonic" + "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" ) @@ -35,27 +37,29 @@ type App struct { validator *customvalidator.CustomValidator JwtConfig jwtutil.JwtConfig Logger *slog.Logger + prematchSvc *odds.ServiceImpl } func NewApp( - port int, validator *customvalidator.CustomValidator, - authSvc *authentication.Service, - logger *slog.Logger, - JwtConfig jwtutil.JwtConfig, - userSvc *user.Service, + port int, validator *customvalidator.CustomValidator, + authSvc *authentication.Service, + logger *slog.Logger, + JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, ticketSvc *ticket.Service, betSvc *bet.Service, walletSvc *wallet.Service, transactionSvc *transaction.Service, branchSvc *branch.Service, notidicationStore notificationservice.NotificationStore, + prematchSvc *odds.ServiceImpl, ) *App { - app := fiber.New(fiber.Config{ - CaseSensitive: true, - DisableHeaderNormalizing: true, - JSONEncoder: sonic.Marshal, - JSONDecoder: sonic.Unmarshal, - }) + app := fiber.New(fiber.Config{ + CaseSensitive: true, + DisableHeaderNormalizing: true, + JSONEncoder: sonic.Marshal, + JSONDecoder: sonic.Unmarshal, + }) app.Use(cors.New(cors.Config{ AllowOrigins: "http://localhost:5173", // Specify your frontend's origin @@ -63,14 +67,14 @@ func NewApp( AllowHeaders: "Content-Type,Authorization", // Specify the allowed headers })) - s := &App{ - fiber: app, - port: port, - authSvc: authSvc, - validator: validator, - logger: logger, - JwtConfig: JwtConfig, - userSvc: userSvc, + s := &App{ + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, + userSvc: userSvc, ticketSvc: ticketSvc, betSvc: betSvc, walletSvc: walletSvc, @@ -78,13 +82,14 @@ func NewApp( branchSvc: branchSvc, NotidicationStore: notidicationStore, Logger: logger, - } + prematchSvc: prematchSvc, + } - s.initAppRoutes() + s.initAppRoutes() - return s + return s } func (a *App) Run() error { - return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) -} + return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) +} \ No newline at end of file diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go new file mode 100644 index 0000000..68ddcf2 --- /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") +} diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go new file mode 100644 index 0000000..cc48b10 --- /dev/null +++ b/internal/web_server/handlers/prematch.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "log/slog" +) + +// GetPrematchOdds godoc +// @Summary Retrieve prematch odds for an event +// @Description Retrieve prematch odds for a specific event by event ID +// @Tags prematch +// @Accept json +// @Produce json +// @Param event_id path string true "Event ID" +// @Success 200 {array} domain.Odd +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /prematch/odds/{event_id} [get] +func GetPrematchOdds(logger *slog.Logger, prematchSvc *odds.ServiceImpl) fiber.Handler { + return func(c *fiber.Ctx) error { + eventID := c.Params("event_id") + if eventID == "" { + logger.Error("GetPrematchOdds failed: missing event_id") + return response.WriteJSON(c, fiber.StatusBadRequest, "Missing event_id", nil, nil) + } + + odds, err := prematchSvc.GetPrematchOdds(c.Context(), eventID) + if err != nil { + logger.Error("GetPrematchOdds failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve odds", nil, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "Prematch odds retrieved successfully", odds, nil) + } +} \ No newline at end of file diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 2e5fc08..b2b6063 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -57,6 +57,9 @@ func (a *App) initAppRoutes() { a.fiber.Get("/company/:id/branch", handlers.GetBranchByCompanyID(a.logger, a.branchSvc, a.validator)) + + a.fiber.Get("/prematch/odds/:event_id", handlers.GetPrematchOdds(a.logger, a.prematchSvc)) + // Swagger a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler())