Merge branch 'auth'

This commit is contained in:
Samuel Tariku 2025-04-11 23:07:57 +03:00
commit 06c6325175
26 changed files with 1537 additions and 49 deletions

View File

@ -1,10 +1,13 @@
package main package main
import ( import (
// "context"
"fmt" "fmt"
"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"
@ -16,12 +19,13 @@ import (
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "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/user"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
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
@ -40,43 +44,50 @@ import (
func main() { func main() {
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)
} }
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)
} }
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())
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)
eventSvc := event.New(cfg.Bet365Token, store)
oddsSvc := odds.New(cfg.Bet365Token, store)
ticketSvc := ticket.NewService(store) ticketSvc := ticket.NewService(store)
betSvc := bet.NewService(store) betSvc := bet.NewService(store)
walletSvc := wallet.NewService(store, store) walletSvc := wallet.NewService(store, store)
transactionSvc := transaction.NewService(store) transactionSvc := transaction.NewService(store)
branchSvc := branch.NewService(store) branchSvc := branch.NewService(store)
notificationRepo := repository.NewNotificationRepository(store) notificationRepo := repository.NewNotificationRepository(store)
notificationSvc := notificationservice.New(notificationRepo, logger, cfg) notificationSvc := notificationservice.New(notificationRepo, logger, cfg)
httpserver.StartDataFetchingCrons(eventSvc, oddsSvc)
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, ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, notificationSvc, }, userSvc, ticketSvc, betSvc, walletSvc, transactionSvc, branchSvc, notificationSvc, oddsSvc)
)
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

@ -88,3 +88,8 @@ DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS otps; DROP TABLE IF EXISTS otps;
DROP TABLE IF EXISTS odds;
DROP TABLE IF EXISTS events;

View File

@ -176,6 +176,54 @@ CREATE TABLE IF NOT EXISTS branch_cashiers (
branch_id BIGINT NOT NULL, branch_id BIGINT NOT NULL,
UNIQUE(user_id, branch_id) 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 ALTER TABLE refresh_tokens
ADD CONSTRAINT fk_refresh_tokens_users FOREIGN KEY (user_id) REFERENCES users(id); ADD CONSTRAINT fk_refresh_tokens_users FOREIGN KEY (user_id) REFERENCES users(id);
ALTER TABLE bets ALTER TABLE bets
@ -294,4 +342,6 @@ VALUES (
TRUE, TRUE,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
); );
--------------------------------------------------Bet365 Data Fetching + Event Managment------------------------------------------------

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;

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

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

View File

@ -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": { "/search/branch": {
"get": { "get": {
"description": "Search branches by name or location", "description": "Search branches by name or location",
@ -2348,6 +2395,66 @@ const docTemplate = `{
"BET_STATUS_ERROR" "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": { "domain.PaymentOption": {
"type": "integer", "type": "integer",
"enum": [ "enum": [

View File

@ -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": { "/search/branch": {
"get": { "get": {
"description": "Search branches by name or location", "description": "Search branches by name or location",
@ -2340,6 +2387,66 @@
"BET_STATUS_ERROR" "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": { "domain.PaymentOption": {
"type": "integer", "type": "integer",
"enum": [ "enum": [

View File

@ -22,6 +22,46 @@ definitions:
- BET_STATUS_WIN - BET_STATUS_WIN
- BET_STATUS_LOSS - BET_STATUS_LOSS
- BET_STATUS_ERROR - 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: domain.PaymentOption:
enum: enum:
- 0 - 0
@ -1495,6 +1535,37 @@ paths:
summary: Create a operation summary: Create a operation
tags: tags:
- branch - 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: /search/branch:
get: get:
consumes: consumes:

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

@ -98,6 +98,30 @@ type CustomerWallet struct {
UpdatedAt pgtype.Timestamp 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 { type Notification struct {
ID string ID string
RecipientID int64 RecipientID int64
@ -115,6 +139,27 @@ type Notification struct {
Metadata []byte 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 { type Otp struct {
ID int64 ID int64
SentTo string SentTo string

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

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

1
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.4 github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1 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/fiber-swagger v1.3.0
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0

2
go.sum
View File

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

@ -20,6 +20,7 @@ var (
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")
ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid") ErrInvalidSMSAPIKey = errors.New("SMS API key is invalid")
ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env")
) )
type Config struct { type Config struct {
@ -34,6 +35,7 @@ type Config struct {
AFRO_SMS_SENDER_NAME string AFRO_SMS_SENDER_NAME string
AFRO_SMS_RECEIVER_PHONE_NUMBER string AFRO_SMS_RECEIVER_PHONE_NUMBER string
ADRO_SMS_HOST_URL string ADRO_SMS_HOST_URL string
Bet365Token string
} }
func NewConfig() (*Config, error) { func NewConfig() (*Config, error) {
@ -126,5 +128,10 @@ func (c *Config) loadEnv() error {
c.ADRO_SMS_HOST_URL = "https://api.afrosms.com" c.ADRO_SMS_HOST_URL = "https://api.afrosms.com"
} }
betToken := os.Getenv("BET365_TOKEN")
if betToken == "" {
return ErrMissingBetToken
}
c.Bet365Token = betToken
return nil return nil
} }

View File

@ -1,8 +1,23 @@
package domain package domain
type Event struct {
type Event struct {} ID string
SportID string
type Outcome struct { 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
}

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

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

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

151
internal/repository/odds.go Normal file
View File

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

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

View File

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

View File

@ -1,22 +1,24 @@
package httpserver package httpserver
import ( import (
"fmt" "fmt"
"log/slog" "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/bet"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/ticket"
"github.com/SamuelTariku/FortuneBet-Backend/internal/services/transaction" "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" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet"
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"
notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/cors"
) )
@ -35,27 +37,29 @@ type App struct {
validator *customvalidator.CustomValidator validator *customvalidator.CustomValidator
JwtConfig jwtutil.JwtConfig JwtConfig jwtutil.JwtConfig
Logger *slog.Logger Logger *slog.Logger
prematchSvc *odds.ServiceImpl
} }
func NewApp( func NewApp(
port int, validator *customvalidator.CustomValidator, port int, validator *customvalidator.CustomValidator,
authSvc *authentication.Service, authSvc *authentication.Service,
logger *slog.Logger, logger *slog.Logger,
JwtConfig jwtutil.JwtConfig, JwtConfig jwtutil.JwtConfig,
userSvc *user.Service, userSvc *user.Service,
ticketSvc *ticket.Service, ticketSvc *ticket.Service,
betSvc *bet.Service, betSvc *bet.Service,
walletSvc *wallet.Service, walletSvc *wallet.Service,
transactionSvc *transaction.Service, transactionSvc *transaction.Service,
branchSvc *branch.Service, branchSvc *branch.Service,
notidicationStore notificationservice.NotificationStore, notidicationStore notificationservice.NotificationStore,
prematchSvc *odds.ServiceImpl,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
DisableHeaderNormalizing: true, DisableHeaderNormalizing: true,
JSONEncoder: sonic.Marshal, JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal, JSONDecoder: sonic.Unmarshal,
}) })
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:5173", // Specify your frontend's origin AllowOrigins: "http://localhost:5173", // Specify your frontend's origin
@ -63,14 +67,14 @@ func NewApp(
AllowHeaders: "Content-Type,Authorization", // Specify the allowed headers AllowHeaders: "Content-Type,Authorization", // Specify the allowed headers
})) }))
s := &App{ s := &App{
fiber: app, fiber: app,
port: port, port: port,
authSvc: authSvc, authSvc: authSvc,
validator: validator, validator: validator,
logger: logger, logger: logger,
JwtConfig: JwtConfig, JwtConfig: JwtConfig,
userSvc: userSvc, userSvc: userSvc,
ticketSvc: ticketSvc, ticketSvc: ticketSvc,
betSvc: betSvc, betSvc: betSvc,
walletSvc: walletSvc, walletSvc: walletSvc,
@ -78,13 +82,14 @@ func NewApp(
branchSvc: branchSvc, branchSvc: branchSvc,
NotidicationStore: notidicationStore, NotidicationStore: notidicationStore,
Logger: logger, Logger: logger,
} prematchSvc: prematchSvc,
}
s.initAppRoutes() s.initAppRoutes()
return s return s
} }
func (a *App) Run() error { func (a *App) Run() error {
return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
} }

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

View File

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

View File

@ -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("/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 // Swagger
a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler()) a.fiber.Get("/swagger/*", fiberSwagger.FiberWrapHandler())