Merge remote-tracking branch 'origin/dev' into ticket-bet

This commit is contained in:
Samuel Tariku 2025-05-20 19:11:30 +03:00
commit ccdfc537c2
20 changed files with 1670 additions and 111 deletions

17
Dockerfile Normal file
View File

@ -0,0 +1,17 @@
# Builder stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -ldflags="-s -w" -o ./bin/web ./cmd/main.go
# Runner stage
FROM alpine:3.21 AS runner
WORKDIR /app
COPY .env .
COPY --from=builder /app/bin/web /app/bin/web
RUN apk add --no-cache ca-certificates
EXPOSE ${PORT}
CMD ["/app/bin/web"]

View File

@ -14,6 +14,9 @@ services:
interval: 5s
timeout: 3s
retries: 5
volumes:
- postgres_data:/var/lib/postgresql/data
migrate:
image: migrate/migrate
volumes:
@ -32,6 +35,37 @@ services:
networks:
- app
app:
build:
context: .
dockerfile: Dockerfile
target: runner
ports:
- ${PORT}:8080
environment:
- DB_URL=postgresql://root:secret@postgres:5432/gh?sslmode=disable
depends_on:
migrate:
condition: service_completed_successfully
networks:
- app
command: ["/app/bin/web"]
test:
build:
context: .
dockerfile: Dockerfile
target: builder
volumes:
- .:/app
command: ["tail", "-f", "/dev/null"]
networks:
- app
networks:
app:
driver: bridge
volumes:
postgres_data:

13
go.mod
View File

@ -7,23 +7,27 @@ require (
github.com/bytedance/sonic v1.13.2
github.com/go-playground/validator/v10 v10.26.0
github.com/gofiber/fiber/v2 v2.52.6
github.com/gofiber/websocket/v2 v2.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.4
github.com/joho/godotenv v1.5.1
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.10.0
github.com/swaggo/fiber-swagger v1.3.0
github.com/swaggo/swag v1.16.4
golang.org/x/crypto v0.36.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/fasthttp/websocket v1.5.8 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
@ -31,7 +35,7 @@ require (
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/gofiber/contrib/websocket v1.3.4
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@ -45,11 +49,10 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.59.0 // indirect
github.com/valyala/fasthttp v1.59.0
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect

14
go.sum
View File

@ -22,10 +22,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8=
github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@ -51,18 +47,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg=
github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs=
github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -118,10 +112,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=

View File

@ -101,6 +101,38 @@ const (
ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY IceHockeyMarket = 170240
)
type AmericanFootballMarket int64
const (
// Main
AMERICAN_FOOTBALL_MONEY_LINE AmericanFootballMarket = 170001
AMERICAN_FOOTBALL_SPREAD AmericanFootballMarket = 170002
AMERICAN_FOOTBALL_TOTAL_POINTS AmericanFootballMarket = 170003
)
type RugbyMarket int64
const (
// Main
RUGBY_MONEY_LINE RugbyMarket = 180001
RUGBY_SPREAD RugbyMarket = 180002
RUGBY_TOTAL_POINTS RugbyMarket = 180003
RUGBY_HANDICAP RugbyMarket = 180004
RUGBY_FIRST_HALF RugbyMarket = 180005
RUGBY_SECOND_HALF RugbyMarket = 180006
)
type BaseballMarket int64
const (
// Main
BASEBALL_MONEY_LINE BaseballMarket = 190001
BASEBALL_SPREAD BaseballMarket = 190002
BASEBALL_TOTAL_RUNS BaseballMarket = 190003
BASEBALL_FIRST_INNING BaseballMarket = 190004
BASEBALL_FIRST_5_INNINGS BaseballMarket = 190005
)
// TODO: Move this into the database so that it can be modified dynamically
var SupportedMarkets = map[int64]bool{
@ -181,4 +213,24 @@ var SupportedMarkets = map[int64]bool{
int64(ICE_HOCKEY_ALTERNATIVE_PUCK_LINE_TWO_WAY): false,
int64(ICE_HOCKEY_ALTERNATIVE_TOTAL_TWO_WAY): false,
// American Football Markets
int64(AMERICAN_FOOTBALL_MONEY_LINE): true,
int64(AMERICAN_FOOTBALL_SPREAD): true,
int64(AMERICAN_FOOTBALL_TOTAL_POINTS): true,
// Rugby Markets
int64(RUGBY_MONEY_LINE): true,
int64(RUGBY_SPREAD): true,
int64(RUGBY_TOTAL_POINTS): true,
int64(RUGBY_HANDICAP): true,
int64(RUGBY_FIRST_HALF): true,
int64(RUGBY_SECOND_HALF): true,
// Baseball Markets
int64(BASEBALL_MONEY_LINE): true,
int64(BASEBALL_SPREAD): true,
int64(BASEBALL_TOTAL_RUNS): true,
int64(BASEBALL_FIRST_INNING): true,
int64(BASEBALL_FIRST_5_INNINGS): true,
}

View File

@ -0,0 +1,290 @@
package domain
import (
"encoding/json"
"strconv"
"strings"
)
// NFLResultResponse represents the structure for NFL game results
type NFLResultResponse struct {
ID string `json:"id"`
SportID string `json:"sport_id"`
Time string `json:"time"`
TimeStatus string `json:"time_status"`
League struct {
ID string `json:"id"`
Name string `json:"name"`
CC string `json:"cc"`
} `json:"league"`
Home struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"home"`
Away struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"away"`
SS string `json:"ss"`
Scores struct {
FirstQuarter Score `json:"1"`
SecondQuarter Score `json:"2"`
ThirdQuarter Score `json:"3"`
FourthQuarter Score `json:"4"`
Overtime Score `json:"5"`
TotalScore Score `json:"7"`
} `json:"scores"`
Stats struct {
FirstDowns []string `json:"first_downs"`
TotalYards []string `json:"total_yards"`
PassingYards []string `json:"passing_yards"`
RushingYards []string `json:"rushing_yards"`
Turnovers []string `json:"turnovers"`
TimeOfPossession []string `json:"time_of_possession"`
ThirdDownEfficiency []string `json:"third_down_efficiency"`
FourthDownEfficiency []string `json:"fourth_down_efficiency"`
} `json:"stats"`
Extra struct {
HomePos string `json:"home_pos"`
AwayPos string `json:"away_pos"`
StadiumData map[string]string `json:"stadium_data"`
Round string `json:"round"`
} `json:"extra"`
Events []map[string]string `json:"events"`
HasLineup int `json:"has_lineup"`
ConfirmedAt string `json:"confirmed_at"`
Bet365ID string `json:"bet365_id"`
}
// RugbyResultResponse represents the structure for Rugby game results
type RugbyResultResponse struct {
ID string `json:"id"`
SportID string `json:"sport_id"`
Time string `json:"time"`
TimeStatus string `json:"time_status"`
League struct {
ID string `json:"id"`
Name string `json:"name"`
CC string `json:"cc"`
} `json:"league"`
Home struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"home"`
Away struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"away"`
SS string `json:"ss"`
Scores struct {
FirstHalf Score `json:"1"`
SecondHalf Score `json:"2"`
TotalScore Score `json:"7"`
} `json:"scores"`
Stats struct {
Tries []string `json:"tries"`
Conversions []string `json:"conversions"`
Penalties []string `json:"penalties"`
DropGoals []string `json:"drop_goals"`
Possession []string `json:"possession"`
Territory []string `json:"territory"`
Lineouts []string `json:"lineouts"`
Scrums []string `json:"scrums"`
PenaltiesConceded []string `json:"penalties_conceded"`
} `json:"stats"`
Extra struct {
HomePos string `json:"home_pos"`
AwayPos string `json:"away_pos"`
StadiumData map[string]string `json:"stadium_data"`
Round string `json:"round"`
} `json:"extra"`
Events []map[string]string `json:"events"`
HasLineup int `json:"has_lineup"`
ConfirmedAt string `json:"confirmed_at"`
Bet365ID string `json:"bet365_id"`
}
// BaseballResultResponse represents the structure for Baseball game results
type BaseballResultResponse struct {
ID string `json:"id"`
SportID string `json:"sport_id"`
Time string `json:"time"`
TimeStatus string `json:"time_status"`
League struct {
ID string `json:"id"`
Name string `json:"name"`
CC string `json:"cc"`
} `json:"league"`
Home struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"home"`
Away struct {
ID string `json:"id"`
Name string `json:"name"`
ImageID string `json:"image_id"`
CC string `json:"cc"`
} `json:"away"`
SS string `json:"ss"`
Scores struct {
FirstInning Score `json:"1"`
SecondInning Score `json:"2"`
ThirdInning Score `json:"3"`
FourthInning Score `json:"4"`
FifthInning Score `json:"5"`
SixthInning Score `json:"6"`
SeventhInning Score `json:"7"`
EighthInning Score `json:"8"`
NinthInning Score `json:"9"`
ExtraInnings Score `json:"10"`
TotalScore Score `json:"7"`
} `json:"scores"`
Stats struct {
Hits []string `json:"hits"`
Errors []string `json:"errors"`
LeftOnBase []string `json:"left_on_base"`
Strikeouts []string `json:"strikeouts"`
Walks []string `json:"walks"`
HomeRuns []string `json:"home_runs"`
TotalBases []string `json:"total_bases"`
BattingAverage []string `json:"batting_average"`
} `json:"stats"`
Extra struct {
HomePos string `json:"home_pos"`
AwayPos string `json:"away_pos"`
StadiumData map[string]string `json:"stadium_data"`
Round string `json:"round"`
} `json:"extra"`
Events []map[string]string `json:"events"`
HasLineup int `json:"has_lineup"`
ConfirmedAt string `json:"confirmed_at"`
Bet365ID string `json:"bet365_id"`
}
// ParseNFLResult parses NFL result from raw JSON data
func ParseNFLResult(data json.RawMessage) (*NFLResultResponse, error) {
var result NFLResultResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
// ParseRugbyResult parses Rugby result from raw JSON data
func ParseRugbyResult(data json.RawMessage) (*RugbyResultResponse, error) {
var result RugbyResultResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
// ParseRugbyUnionResult parses Rugby Union result from raw JSON data
func ParseRugbyUnionResult(data json.RawMessage) (*RugbyResultResponse, error) {
return ParseRugbyResult(data)
}
// ParseRugbyLeagueResult parses Rugby League result from raw JSON data
func ParseRugbyLeagueResult(data json.RawMessage) (*RugbyResultResponse, error) {
return ParseRugbyResult(data)
}
// ParseBaseballResult parses Baseball result from raw JSON data
func ParseBaseballResult(data json.RawMessage) (*BaseballResultResponse, error) {
var result BaseballResultResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetNFLWinner determines the winner of an NFL game
func GetNFLWinner(result *NFLResultResponse) (string, error) {
homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home)
if err != nil {
return "", err
}
awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away)
if err != nil {
return "", err
}
if homeScore > awayScore {
return result.Home.Name, nil
} else if awayScore > homeScore {
return result.Away.Name, nil
}
return "Draw", nil
}
// GetRugbyWinner determines the winner of a Rugby game
func GetRugbyWinner(result *RugbyResultResponse) (string, error) {
homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home)
if err != nil {
return "", err
}
awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away)
if err != nil {
return "", err
}
if homeScore > awayScore {
return result.Home.Name, nil
} else if awayScore > homeScore {
return result.Away.Name, nil
}
return "Draw", nil
}
// GetBaseballWinner determines the winner of a Baseball game
func GetBaseballWinner(result *BaseballResultResponse) (string, error) {
homeScore, err := strconv.Atoi(result.Scores.TotalScore.Home)
if err != nil {
return "", err
}
awayScore, err := strconv.Atoi(result.Scores.TotalScore.Away)
if err != nil {
return "", err
}
if homeScore > awayScore {
return result.Home.Name, nil
} else if awayScore > homeScore {
return result.Away.Name, nil
}
return "Draw", nil
}
// FormatNFLScore formats the NFL score in a readable format
func FormatNFLScore(result *NFLResultResponse) string {
return strings.Join([]string{
result.Home.Name + " " + result.Scores.TotalScore.Home,
result.Away.Name + " " + result.Scores.TotalScore.Away,
}, " - ")
}
// FormatRugbyScore formats the Rugby score in a readable format
func FormatRugbyScore(result *RugbyResultResponse) string {
return strings.Join([]string{
result.Home.Name + " " + result.Scores.TotalScore.Home,
result.Away.Name + " " + result.Scores.TotalScore.Away,
}, " - ")
}
// FormatBaseballScore formats the Baseball score in a readable format
func FormatBaseballScore(result *BaseballResultResponse) string {
return strings.Join([]string{
result.Home.Name + " " + result.Scores.TotalScore.Home,
result.Away.Name + " " + result.Scores.TotalScore.Away,
}, " - ")
}

View File

@ -4,7 +4,7 @@ import (
"context"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/gofiber/websocket/v2"
"github.com/gorilla/websocket"
)
type NotificationStore interface {

View File

@ -11,12 +11,14 @@ import (
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers"
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws"
afro "github.com/amanuelabay/afrosms-go"
"github.com/gofiber/websocket/v2"
"github.com/gorilla/websocket"
)
type Service struct {
repo repository.NotificationRepository
Hub *ws.NotificationHub
connections sync.Map
notificationCh chan *domain.Notification
stopCh chan struct{}
@ -24,9 +26,11 @@ type Service struct {
logger *slog.Logger
}
func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) NotificationStore {
func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service {
hub := ws.NewNotificationHub()
svc := &Service{
repo: repo,
Hub: hub,
logger: logger,
connections: sync.Map{},
notificationCh: make(chan *domain.Notification, 1000),
@ -34,6 +38,7 @@ func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *confi
config: cfg,
}
go hub.Run()
go svc.startWorker()
go svc.startRetryWorker()
@ -63,10 +68,18 @@ func (s *Service) SendNotification(ctx context.Context, notification *domain.Not
notification = created
if notification.DeliveryChannel == domain.DeliveryChannelInApp {
s.Hub.Broadcast <- map[string]interface{}{
"type": "CREATED_NOTIFICATION",
"recipient_id": notification.RecipientID,
"payload": notification,
}
}
select {
case s.notificationCh <- notification:
default:
s.logger.Error("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID)
s.logger.Warn("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID)
}
return nil
@ -78,6 +91,21 @@ func (s *Service) MarkAsRead(ctx context.Context, notificationID string, recipie
s.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err)
return err
}
// count, err := s.repo.CountUnreadNotifications(ctx, recipientID)
// if err != nil {
// s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err)
// return err
// }
// s.Hub.Broadcast <- map[string]interface{}{
// "type": "COUNT_NOT_OPENED_NOTIFICATION",
// "recipient_id": recipientID,
// "payload": map[string]int{
// "not_opened_notifications_count": int(count),
// },
// }
s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID)
return nil
}
@ -99,7 +127,6 @@ func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *we
}
func (s *Service) DisconnectWebSocket(recipientID int64) {
s.connections.Delete(recipientID)
if conn, loaded := s.connections.LoadAndDelete(recipientID); loaded {
conn.(*websocket.Conn).Close()
s.logger.Info("[NotificationSvc.DisconnectWebSocket] Disconnected WebSocket", "recipientID", recipientID)
@ -160,21 +187,26 @@ func (s *Service) ListRecipientIDs(ctx context.Context, receiver domain.Notifica
func (s *Service) handleNotification(notification *domain.Notification) {
ctx := context.Background()
if conn, ok := s.connections.Load(notification.RecipientID); ok {
data, err := notification.ToJSON()
switch notification.DeliveryChannel {
case domain.DeliveryChannelSMS:
err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message)
if err != nil {
s.logger.Error("[NotificationSvc.HandleNotification] Failed to serialize notification", "id", notification.ID, "error", err)
return
}
if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Error("[NotificationSvc.HandleNotification] Failed to send WebSocket message", "id", notification.ID, "error", err)
notification.DeliveryStatus = domain.DeliveryStatusFailed
} else {
notification.DeliveryStatus = domain.DeliveryStatusSent
}
} else {
s.logger.Warn("[NotificationSvc.HandleNotification] No WebSocket connection for recipient", "recipientID", notification.RecipientID)
notification.DeliveryStatus = domain.DeliveryStatusFailed
case domain.DeliveryChannelEmail:
err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message)
if err != nil {
notification.DeliveryStatus = domain.DeliveryStatusFailed
} else {
notification.DeliveryStatus = domain.DeliveryStatusSent
}
default:
if notification.DeliveryChannel != domain.DeliveryChannelInApp {
s.logger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", "channel", notification.DeliveryChannel)
notification.DeliveryStatus = domain.DeliveryStatusFailed
}
}
if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil {
@ -210,13 +242,17 @@ func (s *Service) retryFailedNotifications() {
go func(notification *domain.Notification) {
for attempt := 0; attempt < 3; attempt++ {
time.Sleep(time.Duration(attempt) * time.Second)
if conn, ok := s.connections.Load(notification.RecipientID); ok {
data, err := notification.ToJSON()
if err != nil {
s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to serialize notification for retry", "id", notification.ID, "error", err)
continue
if notification.DeliveryChannel == domain.DeliveryChannelSMS {
if err := s.SendSMS(ctx, notification.RecipientID, notification.Payload.Message); err == nil {
notification.DeliveryStatus = domain.DeliveryStatusSent
if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil {
s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err)
}
s.logger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", "id", notification.ID)
return
}
if err := conn.(*websocket.Conn).WriteMessage(websocket.TextMessage, data); err == nil {
} else if notification.DeliveryChannel == domain.DeliveryChannelEmail {
if err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message); err == nil {
notification.DeliveryStatus = domain.DeliveryStatusSent
if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil {
s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err)
@ -230,3 +266,4 @@ func (s *Service) retryFailedNotifications() {
}(notification)
}
}

View File

@ -954,3 +954,35 @@ func evaluateTiedAfterRegulation(outcome domain.BetOutcome, scores []struct{ Hom
return domain.OUTCOME_STATUS_ERROR, fmt.Errorf("invalid oddname: %s", outcome.OddName)
}
// evaluateRugbyOutcome evaluates the outcome of a Rugby bet
func evaluateRugbyOutcome(outcome domain.BetOutcome, result *domain.RugbyResultResponse) (domain.OutcomeStatus, error) {
finalScore := parseSS(result.SS)
switch outcome.MarketName {
case "Money Line":
return evaluateRugbyMoneyLine(outcome, finalScore)
case "Spread":
return evaluateRugbySpread(outcome, finalScore)
case "Total Points":
return evaluateRugbyTotalPoints(outcome, finalScore)
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported rugby market: %s", outcome.MarketName)
}
}
// evaluateBaseballOutcome evaluates the outcome of a Baseball bet
func evaluateBaseballOutcome(outcome domain.BetOutcome, result *domain.BaseballResultResponse) (domain.OutcomeStatus, error) {
finalScore := parseSS(result.SS)
switch outcome.MarketName {
case "Money Line":
return evaluateBaseballMoneyLine(outcome, finalScore)
case "Spread":
return evaluateBaseballSpread(outcome, finalScore)
case "Total Runs":
return evaluateBaseballTotalRuns(outcome, finalScore)
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported baseball market: %s", outcome.MarketName)
}
}

View File

@ -261,30 +261,24 @@ func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID, spo
switch sportID {
case domain.FOOTBALL:
result, err = s.parseFootball(resultResp.Results[0], eventID, oddID, marketID, outcome)
if err != nil {
s.logger.Error("Failed to parse football", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
case domain.BASKETBALL:
result, err = s.parseBasketball(resultResp.Results[0], eventID, oddID, marketID, outcome)
if err != nil {
s.logger.Error("Failed to parse basketball", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
case domain.ICE_HOCKEY:
result, err = s.parseIceHockey(resultResp.Results[0], eventID, oddID, marketID, outcome)
if err != nil {
s.logger.Error("Failed to parse ice hockey", "event id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
case domain.AMERICAN_FOOTBALL:
result, err = s.parseNFL(resultResp.Results[0], eventID, oddID, marketID, outcome)
case domain.RUGBY_UNION:
result, err = s.parseRugbyUnion(resultResp.Results[0], eventID, oddID, marketID, outcome)
case domain.RUGBY_LEAGUE:
result, err = s.parseRugbyLeague(resultResp.Results[0], eventID, oddID, marketID, outcome)
case domain.BASEBALL:
result, err = s.parseBaseball(resultResp.Results[0], eventID, oddID, marketID, outcome)
default:
s.logger.Error("Unsupported sport", "sport", sportID)
return domain.CreateResult{}, fmt.Errorf("unsupported sport: %v", sportID)
}
return result, nil
}
func (s *Service) parseTimeStatus(timeStatusStr string) (bool, error) {
@ -422,6 +416,124 @@ func (s *Service) parseIceHockey(response json.RawMessage, eventID, oddID, marke
}
func (s *Service) parseNFL(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) {
var nflResp domain.NFLResultResponse
if err := json.Unmarshal(resultRes, &nflResp); err != nil {
s.logger.Error("Failed to unmarshal NFL result", "event_id", eventID, "error", err)
return domain.CreateResult{}, err
}
if nflResp.TimeStatus != "3" {
s.logger.Warn("Match not yet completed", "event_id", eventID)
return domain.CreateResult{}, fmt.Errorf("match not yet completed")
}
finalScore := parseSS(nflResp.SS)
var status domain.OutcomeStatus
var err error
switch outcome.MarketName {
case "Money Line":
status, err = evaluateNFLMoneyLine(outcome, finalScore)
case "Spread":
status, err = evaluateNFLSpread(outcome, finalScore)
case "Total Points":
status, err = evaluateNFLTotalPoints(outcome, finalScore)
default:
return domain.CreateResult{}, fmt.Errorf("unsupported market: %s", outcome.MarketName)
}
if err != nil {
s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
return domain.CreateResult{
BetOutcomeID: outcome.ID,
EventID: eventID,
OddID: oddID,
MarketID: marketID,
Status: status,
Score: nflResp.SS,
}, nil
}
func (s *Service) parseRugbyUnion(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) {
var rugbyResp domain.RugbyResultResponse
if err := json.Unmarshal(resultRes, &rugbyResp); err != nil {
s.logger.Error("Failed to unmarshal Rugby Union result", "event_id", eventID, "error", err)
return domain.CreateResult{}, err
}
if rugbyResp.TimeStatus != "3" {
s.logger.Warn("Match not yet completed", "event_id", eventID)
return domain.CreateResult{}, fmt.Errorf("match not yet completed")
}
status, err := evaluateRugbyOutcome(outcome, &rugbyResp)
if err != nil {
s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
return domain.CreateResult{
BetOutcomeID: outcome.ID,
EventID: eventID,
OddID: oddID,
MarketID: marketID,
Status: status,
Score: rugbyResp.SS,
}, nil
}
func (s *Service) parseRugbyLeague(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) {
var rugbyResp domain.RugbyResultResponse
if err := json.Unmarshal(resultRes, &rugbyResp); err != nil {
s.logger.Error("Failed to unmarshal Rugby League result", "event_id", eventID, "error", err)
return domain.CreateResult{}, err
}
if rugbyResp.TimeStatus != "3" {
s.logger.Warn("Match not yet completed", "event_id", eventID)
return domain.CreateResult{}, fmt.Errorf("match not yet completed")
}
status, err := evaluateRugbyOutcome(outcome, &rugbyResp)
if err != nil {
s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
return domain.CreateResult{
BetOutcomeID: outcome.ID,
EventID: eventID,
OddID: oddID,
MarketID: marketID,
Status: status,
Score: rugbyResp.SS,
}, nil
}
func (s *Service) parseBaseball(resultRes json.RawMessage, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) {
var baseballResp domain.BaseballResultResponse
if err := json.Unmarshal(resultRes, &baseballResp); err != nil {
s.logger.Error("Failed to unmarshal Baseball result", "event_id", eventID, "error", err)
return domain.CreateResult{}, err
}
if baseballResp.TimeStatus != "3" {
s.logger.Warn("Match not yet completed", "event_id", eventID)
return domain.CreateResult{}, fmt.Errorf("match not yet completed")
}
status, err := evaluateBaseballOutcome(outcome, &baseballResp)
if err != nil {
s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err)
return domain.CreateResult{}, err
}
return domain.CreateResult{
BetOutcomeID: outcome.ID,
EventID: eventID,
OddID: oddID,
MarketID: marketID,
Status: status,
Score: baseballResp.SS,
}, nil
}
func parseScore(home string, away string) struct{ Home, Away int } {
homeVal, _ := strconv.Atoi(strings.TrimSpace(home))
awaVal, _ := strconv.Atoi(strings.TrimSpace(away))
@ -617,3 +729,22 @@ func (s *Service) evaluateIceHockeyOutcome(outcome domain.BetOutcome, res domain
return domain.OUTCOME_STATUS_PENDING, nil
}
func (s *Service) evaluateNFLOutcome(outcome domain.BetOutcome, finalScore struct{ Home, Away int }) (domain.OutcomeStatus, error) {
if !domain.SupportedMarkets[outcome.MarketID] {
s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName)
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName)
}
switch outcome.MarketID {
case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE):
return evaluateNFLMoneyLine(outcome, finalScore)
case int64(domain.AMERICAN_FOOTBALL_SPREAD):
return evaluateNFLSpread(outcome, finalScore)
case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS):
return evaluateNFLTotalPoints(outcome, finalScore)
default:
s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName)
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName)
}
}

View File

@ -0,0 +1,280 @@
package result
import (
"fmt"
"strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
// NFL evaluations
func evaluateNFLMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
switch outcome.OddHeader {
case "1":
if score.Home > score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "2":
if score.Away > score.Home {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
}
func evaluateNFLSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap)
}
adjustedHomeScore := float64(score.Home)
adjustedAwayScore := float64(score.Away)
if outcome.OddHeader == "1" {
adjustedHomeScore += handicap
} else if outcome.OddHeader == "2" {
adjustedAwayScore += handicap
} else {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
if adjustedHomeScore > adjustedAwayScore {
if outcome.OddHeader == "1" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if adjustedHomeScore < adjustedAwayScore {
if outcome.OddHeader == "2" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_VOID, nil
}
func evaluateNFLTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
totalPoints := float64(score.Home + score.Away)
threshold, err := strconv.ParseFloat(outcome.OddName, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName)
}
if outcome.OddHeader == "Over" {
if totalPoints > threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalPoints == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if outcome.OddHeader == "Under" {
if totalPoints < threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalPoints == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
// evaluateRugbyMoneyLine evaluates Rugby money line bets
func evaluateRugbyMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
switch outcome.OddHeader {
case "1":
if score.Home > score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "2":
if score.Away > score.Home {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
}
// evaluateRugbySpread evaluates Rugby spread bets
func evaluateRugbySpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap)
}
adjustedHomeScore := float64(score.Home)
adjustedAwayScore := float64(score.Away)
if outcome.OddHeader == "1" {
adjustedHomeScore += handicap
} else if outcome.OddHeader == "2" {
adjustedAwayScore += handicap
} else {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
if adjustedHomeScore > adjustedAwayScore {
if outcome.OddHeader == "1" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if adjustedHomeScore < adjustedAwayScore {
if outcome.OddHeader == "2" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_VOID, nil
}
// evaluateRugbyTotalPoints evaluates Rugby total points bets
func evaluateRugbyTotalPoints(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
totalPoints := float64(score.Home + score.Away)
threshold, err := strconv.ParseFloat(outcome.OddName, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName)
}
if outcome.OddHeader == "Over" {
if totalPoints > threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalPoints == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if outcome.OddHeader == "Under" {
if totalPoints < threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalPoints == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
// evaluateBaseballMoneyLine evaluates Baseball money line bets
func evaluateBaseballMoneyLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
switch outcome.OddHeader {
case "1":
if score.Home > score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "2":
if score.Away > score.Home {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
}
// evaluateBaseballSpread evaluates Baseball spread bets
func evaluateBaseballSpread(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap)
}
adjustedHomeScore := float64(score.Home)
adjustedAwayScore := float64(score.Away)
if outcome.OddHeader == "1" {
adjustedHomeScore += handicap
} else if outcome.OddHeader == "2" {
adjustedAwayScore += handicap
} else {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
if adjustedHomeScore > adjustedAwayScore {
if outcome.OddHeader == "1" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if adjustedHomeScore < adjustedAwayScore {
if outcome.OddHeader == "2" {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_VOID, nil
}
// evaluateBaseballTotalRuns evaluates Baseball total runs bets
func evaluateBaseballTotalRuns(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
totalRuns := float64(score.Home + score.Away)
threshold, err := strconv.ParseFloat(outcome.OddName, 64)
if err != nil {
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName)
}
if outcome.OddHeader == "Over" {
if totalRuns > threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalRuns == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
} else if outcome.OddHeader == "Under" {
if totalRuns < threshold {
return domain.OUTCOME_STATUS_WIN, nil
} else if totalRuns == threshold {
return domain.OUTCOME_STATUS_VOID, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
}
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
// evaluateBaseballFirstInning evaluates Baseball first inning bets
func evaluateBaseballFirstInning(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
switch outcome.OddHeader {
case "1":
if score.Home > score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "2":
if score.Away > score.Home {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "X":
if score.Home == score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
}
// evaluateBaseballFirst5Innings evaluates Baseball first 5 innings bets
func evaluateBaseballFirst5Innings(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
switch outcome.OddHeader {
case "1":
if score.Home > score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "2":
if score.Away > score.Home {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
case "X":
if score.Home == score.Away {
return domain.OUTCOME_STATUS_WIN, nil
}
return domain.OUTCOME_STATUS_LOSS, nil
default:
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
}
}

View File

@ -0,0 +1,303 @@
package result
import (
"testing"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/stretchr/testify/assert"
)
// TestNFLMarkets covers all American Football (NFL) market types defined in the domain.
// For each market (Money Line, Spread, Total Points), it tests home/away win, draw, void, and invalid input scenarios.
func TestNFLMarkets(t *testing.T) {
t.Log("Testing NFL (American Football) Markets")
markets := []struct {
marketID int64
name string
}{
{int64(domain.AMERICAN_FOOTBALL_MONEY_LINE), "MONEY_LINE"},
{int64(domain.AMERICAN_FOOTBALL_SPREAD), "SPREAD"},
{int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS), "TOTAL_POINTS"},
}
for _, m := range markets {
t.Run(m.name, func(t *testing.T) {
// Each subtest below covers a key scenario for the given NFL market.
switch m.marketID {
case int64(domain.AMERICAN_FOOTBALL_MONEY_LINE):
// Home win, away win, draw, and invalid OddHeader for Money Line
t.Run("Home Win", func(t *testing.T) {
status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 21, Away: 14})
t.Logf("Market: %s, Scenario: Home Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win", func(t *testing.T) {
status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 14, Away: 21})
t.Logf("Market: %s, Scenario: Away Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Draw", func(t *testing.T) {
status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 17, Away: 17})
t.Logf("Market: %s, Scenario: Draw", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status)
})
t.Run("Invalid OddHeader", func(t *testing.T) {
status, err := evaluateNFLMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7})
t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.AMERICAN_FOOTBALL_SPREAD):
t.Run("Home Win with Handicap", func(t *testing.T) {
status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-3.5"}, struct{ Home, Away int }{Home: 24, Away: 20})
t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win with Handicap", func(t *testing.T) {
status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+3.5"}, struct{ Home, Away int }{Home: 20, Away: 24})
t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric Handicap", func(t *testing.T) {
status, err := evaluateNFLSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14})
t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.AMERICAN_FOOTBALL_TOTAL_POINTS):
t.Run("Over Win", func(t *testing.T) {
status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "44.5"}, struct{ Home, Away int }{Home: 30, Away: 20})
t.Logf("Market: %s, Scenario: Over Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Under Win", func(t *testing.T) {
status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "44.5"}, struct{ Home, Away int }{Home: 20, Away: 17})
t.Logf("Market: %s, Scenario: Under Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "37"}, struct{ Home, Away int }{Home: 20, Away: 17})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric OddName", func(t *testing.T) {
status, err := evaluateNFLTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 17})
t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
}
})
}
}
// TestRugbyMarkets covers all Rugby (Union & League) market types defined in the domain.
// For each market (Money Line, Spread, Handicap, Total Points), it tests home/away win, draw, void, and invalid input scenarios.
func TestRugbyMarkets(t *testing.T) {
t.Log("Testing Rugby Markets (Union & League)")
markets := []struct {
marketID int64
name string
}{
{int64(domain.RUGBY_MONEY_LINE), "MONEY_LINE"},
{int64(domain.RUGBY_SPREAD), "SPREAD"},
{int64(domain.RUGBY_TOTAL_POINTS), "TOTAL_POINTS"},
{int64(domain.RUGBY_HANDICAP), "HANDICAP"},
}
for _, m := range markets {
t.Run(m.name, func(t *testing.T) {
// Each subtest below covers a key scenario for the given Rugby market.
switch m.marketID {
case int64(domain.RUGBY_MONEY_LINE):
// Home win, away win, draw, and invalid OddHeader for Money Line
t.Run("Home Win", func(t *testing.T) {
status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 30, Away: 20})
t.Logf("Market: %s, Scenario: Home Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win", func(t *testing.T) {
status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 20, Away: 30})
t.Logf("Market: %s, Scenario: Away Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Draw", func(t *testing.T) {
status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 25, Away: 25})
t.Logf("Market: %s, Scenario: Draw", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status)
})
t.Run("Invalid OddHeader", func(t *testing.T) {
status, err := evaluateRugbyMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7})
t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.RUGBY_SPREAD), int64(domain.RUGBY_HANDICAP):
t.Run("Home Win with Handicap", func(t *testing.T) {
status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-6.5"}, struct{ Home, Away int }{Home: 28, Away: 20})
t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win with Handicap", func(t *testing.T) {
status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+6.5"}, struct{ Home, Away int }{Home: 20, Away: 28})
t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 21, Away: 21})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric Handicap", func(t *testing.T) {
status, err := evaluateRugbySpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 21, Away: 14})
t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.RUGBY_TOTAL_POINTS):
t.Run("Over Win", func(t *testing.T) {
status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "40.5"}, struct{ Home, Away int }{Home: 25, Away: 20})
t.Logf("Market: %s, Scenario: Over Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Under Win", func(t *testing.T) {
status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "40.5"}, struct{ Home, Away int }{Home: 15, Away: 20})
t.Logf("Market: %s, Scenario: Under Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "35"}, struct{ Home, Away int }{Home: 20, Away: 15})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric OddName", func(t *testing.T) {
status, err := evaluateRugbyTotalPoints(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 20, Away: 15})
t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
}
})
}
}
// TestBaseballMarkets covers all Baseball market types defined in the domain.
// For each market (Money Line, Spread, Total Runs), it tests home/away win, draw, void, and invalid input scenarios.
func TestBaseballMarkets(t *testing.T) {
t.Log("Testing Baseball Markets")
markets := []struct {
marketID int64
name string
}{
{int64(domain.BASEBALL_MONEY_LINE), "MONEY_LINE"},
{int64(domain.BASEBALL_SPREAD), "SPREAD"},
{int64(domain.BASEBALL_TOTAL_RUNS), "TOTAL_RUNS"},
}
for _, m := range markets {
t.Run(m.name, func(t *testing.T) {
// Each subtest below covers a key scenario for the given Baseball market.
switch m.marketID {
case int64(domain.BASEBALL_MONEY_LINE):
// Home win, away win, draw, and invalid OddHeader for Money Line
t.Run("Home Win", func(t *testing.T) {
status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 6, Away: 3})
t.Logf("Market: %s, Scenario: Home Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win", func(t *testing.T) {
status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2"}, struct{ Home, Away int }{Home: 2, Away: 5})
t.Logf("Market: %s, Scenario: Away Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Draw", func(t *testing.T) {
status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1"}, struct{ Home, Away int }{Home: 4, Away: 4})
t.Logf("Market: %s, Scenario: Draw", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_LOSS, status)
})
t.Run("Invalid OddHeader", func(t *testing.T) {
status, err := evaluateBaseballMoneyLine(domain.BetOutcome{MarketID: m.marketID, OddHeader: "X"}, struct{ Home, Away int }{Home: 10, Away: 7})
t.Logf("Market: %s, Scenario: Invalid OddHeader", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.BASEBALL_SPREAD):
t.Run("Home Win with Handicap", func(t *testing.T) {
status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "-1.5"}, struct{ Home, Away int }{Home: 5, Away: 3})
t.Logf("Market: %s, Scenario: Home Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Away Win with Handicap", func(t *testing.T) {
status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "2", OddHandicap: "+1.5"}, struct{ Home, Away int }{Home: 3, Away: 5})
t.Logf("Market: %s, Scenario: Away Win with Handicap", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "0"}, struct{ Home, Away int }{Home: 4, Away: 4})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric Handicap", func(t *testing.T) {
status, err := evaluateBaseballSpread(domain.BetOutcome{MarketID: m.marketID, OddHeader: "1", OddHandicap: "notanumber"}, struct{ Home, Away int }{Home: 5, Away: 3})
t.Logf("Market: %s, Scenario: Non-numeric Handicap", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
case int64(domain.BASEBALL_TOTAL_RUNS):
t.Run("Over Win", func(t *testing.T) {
status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7.5"}, struct{ Home, Away int }{Home: 5, Away: 4})
t.Logf("Market: %s, Scenario: Over Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Under Win", func(t *testing.T) {
status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Under", OddName: "7.5"}, struct{ Home, Away int }{Home: 2, Away: 3})
t.Logf("Market: %s, Scenario: Under Win", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_WIN, status)
})
t.Run("Push (Void)", func(t *testing.T) {
status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "7"}, struct{ Home, Away int }{Home: 4, Away: 3})
t.Logf("Market: %s, Scenario: Push (Void)", m.name)
assert.NoError(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_VOID, status)
})
t.Run("Non-numeric OddName", func(t *testing.T) {
status, err := evaluateBaseballTotalRuns(domain.BetOutcome{MarketID: m.marketID, OddHeader: "Over", OddName: "notanumber"}, struct{ Home, Away int }{Home: 4, Away: 3})
t.Logf("Market: %s, Scenario: Non-numeric OddName", m.name)
assert.Error(t, err)
assert.Equal(t, domain.OUTCOME_STATUS_PENDING, status)
})
}
})
}
}

View File

@ -0,0 +1,189 @@
package services
import (
"encoding/json"
"fmt"
"time"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
)
// ResultCheckerService handles the checking of game results
type ResultCheckerService struct {
// Add any dependencies here (e.g., repositories, external APIs)
}
// NewResultCheckerService creates a new instance of ResultCheckerService
func NewResultCheckerService() *ResultCheckerService {
return &ResultCheckerService{}
}
// CheckNFLResult checks the result of an NFL game
func (s *ResultCheckerService) CheckNFLResult(data json.RawMessage) (*domain.Result, error) {
nflResult, err := domain.ParseNFLResult(data)
if err != nil {
return nil, fmt.Errorf("failed to parse NFL result: %w", err)
}
winner, err := domain.GetNFLWinner(nflResult)
if err != nil {
return nil, fmt.Errorf("failed to determine NFL winner: %w", err)
}
score := domain.FormatNFLScore(nflResult)
return &domain.Result{
Status: determineOutcomeStatus(winner, nflResult.Home.Name, nflResult.Away.Name),
Score: score,
FullTimeScore: score,
SS: nflResult.SS,
Scores: map[string]domain.Score{
"1": nflResult.Scores.FirstQuarter,
"2": nflResult.Scores.SecondQuarter,
"3": nflResult.Scores.ThirdQuarter,
"4": nflResult.Scores.FourthQuarter,
"5": nflResult.Scores.Overtime,
"7": nflResult.Scores.TotalScore,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// determineOutcomeStatus determines the outcome status based on the winner and teams
func determineOutcomeStatus(winner, homeTeam, awayTeam string) domain.OutcomeStatus {
if winner == "Draw" {
return domain.OUTCOME_STATUS_VOID
}
if winner == homeTeam {
return domain.OUTCOME_STATUS_WIN
}
if winner == awayTeam {
return domain.OUTCOME_STATUS_LOSS
}
return domain.OUTCOME_STATUS_PENDING
}
// CheckRugbyResult checks the result of a Rugby game
func (s *ResultCheckerService) CheckRugbyResult(data json.RawMessage) (*domain.Result, error) {
rugbyResult, err := domain.ParseRugbyResult(data)
if err != nil {
return nil, fmt.Errorf("failed to parse Rugby result: %w", err)
}
winner, err := domain.GetRugbyWinner(rugbyResult)
if err != nil {
return nil, fmt.Errorf("failed to determine Rugby winner: %w", err)
}
score := domain.FormatRugbyScore(rugbyResult)
return &domain.Result{
Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name),
Score: score,
FullTimeScore: score,
SS: rugbyResult.SS,
Scores: map[string]domain.Score{
"1": rugbyResult.Scores.FirstHalf,
"2": rugbyResult.Scores.SecondHalf,
"7": rugbyResult.Scores.TotalScore,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// CheckBaseballResult checks the result of a Baseball game
func (s *ResultCheckerService) CheckBaseballResult(data json.RawMessage) (*domain.Result, error) {
baseballResult, err := domain.ParseBaseballResult(data)
if err != nil {
return nil, fmt.Errorf("failed to parse Baseball result: %w", err)
}
winner, err := domain.GetBaseballWinner(baseballResult)
if err != nil {
return nil, fmt.Errorf("failed to determine Baseball winner: %w", err)
}
score := domain.FormatBaseballScore(baseballResult)
return &domain.Result{
Status: determineOutcomeStatus(winner, baseballResult.Home.Name, baseballResult.Away.Name),
Score: score,
FullTimeScore: score,
SS: baseballResult.SS,
Scores: map[string]domain.Score{
"1": baseballResult.Scores.FirstInning,
"2": baseballResult.Scores.SecondInning,
"3": baseballResult.Scores.ThirdInning,
"4": baseballResult.Scores.FourthInning,
"5": baseballResult.Scores.FifthInning,
"6": baseballResult.Scores.SixthInning,
"7": baseballResult.Scores.SeventhInning,
"8": baseballResult.Scores.EighthInning,
"9": baseballResult.Scores.NinthInning,
"10": baseballResult.Scores.ExtraInnings,
"T": baseballResult.Scores.TotalScore,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// CheckRugbyUnionResult checks the result of a Rugby Union game
func (s *ResultCheckerService) CheckRugbyUnionResult(data json.RawMessage) (*domain.Result, error) {
rugbyResult, err := domain.ParseRugbyUnionResult(data)
if err != nil {
return nil, fmt.Errorf("failed to parse Rugby Union result: %w", err)
}
winner, err := domain.GetRugbyWinner(rugbyResult)
if err != nil {
return nil, fmt.Errorf("failed to determine Rugby Union winner: %w", err)
}
score := domain.FormatRugbyScore(rugbyResult)
return &domain.Result{
Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name),
Score: score,
FullTimeScore: score,
SS: rugbyResult.SS,
Scores: map[string]domain.Score{
"1": rugbyResult.Scores.FirstHalf,
"2": rugbyResult.Scores.SecondHalf,
"7": rugbyResult.Scores.TotalScore,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// CheckRugbyLeagueResult checks the result of a Rugby League game
func (s *ResultCheckerService) CheckRugbyLeagueResult(data json.RawMessage) (*domain.Result, error) {
rugbyResult, err := domain.ParseRugbyLeagueResult(data)
if err != nil {
return nil, fmt.Errorf("failed to parse Rugby League result: %w", err)
}
winner, err := domain.GetRugbyWinner(rugbyResult)
if err != nil {
return nil, fmt.Errorf("failed to determine Rugby League winner: %w", err)
}
score := domain.FormatRugbyScore(rugbyResult)
return &domain.Result{
Status: determineOutcomeStatus(winner, rugbyResult.Home.Name, rugbyResult.Away.Name),
Score: score,
FullTimeScore: score,
SS: rugbyResult.SS,
Scores: map[string]domain.Score{
"1": rugbyResult.Scores.FirstHalf,
"2": rugbyResult.Scores.SecondHalf,
"7": rugbyResult.Scores.TotalScore,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}

View File

@ -29,7 +29,7 @@ import (
type App struct {
fiber *fiber.App
logger *slog.Logger
NotidicationStore notificationservice.NotificationStore
NotidicationStore *notificationservice.Service
referralSvc referralservice.ReferralStore
port int
authSvc *authentication.Service
@ -61,7 +61,7 @@ func NewApp(
transactionSvc *transaction.Service,
branchSvc *branch.Service,
companySvc *company.Service,
notidicationStore notificationservice.NotificationStore,
notidicationStore *notificationservice.Service,
prematchSvc *odds.ServiceImpl,
eventSvc event.Service,
referralSvc referralservice.ReferralStore,
@ -76,9 +76,9 @@ func NewApp(
})
app.Use(cors.New(cors.Config{
AllowOrigins: "*", // Specify your frontend's origin
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS", // Specify the allowed HTTP methods
AllowHeaders: "Content-Type,Authorization,platform", // Specify the allowed headers
AllowOrigins: "*",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Content-Type,Authorization,platform",
// AllowCredentials: true,
}))

View File

@ -22,7 +22,7 @@ import (
type Handler struct {
logger *slog.Logger
notificationSvc notificationservice.NotificationStore
notificationSvc *notificationservice.Service
userSvc *user.Service
referralSvc referralservice.ReferralStore
walletSvc *wallet.Service
@ -41,7 +41,7 @@ type Handler struct {
func New(
logger *slog.Logger,
notificationSvc notificationservice.NotificationStore,
notificationSvc *notificationservice.Service,
validator *customvalidator.CustomValidator,
walletSvc *wallet.Service,
referralSvc referralservice.ReferralStore,

View File

@ -3,53 +3,100 @@ package handlers
import (
"context"
"encoding/json"
"net"
"net/http"
"strconv"
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
"github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gorilla/websocket"
"github.com/valyala/fasthttp/fasthttpadaptor"
)
func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
if !websocket.IsWebSocketUpgrade(c) {
h.logger.Warn("WebSocket upgrade required")
return fiber.ErrUpgradeRequired
}
func hijackHTTP(c *fiber.Ctx) (net.Conn, http.ResponseWriter, error) {
var rw http.ResponseWriter
var conn net.Conn
// This is a trick: fasthttpadaptor gives us the HTTP interfaces
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
return
}
var err error
conn, _, err = hj.Hijack()
if err != nil {
return
}
rw = w
})
fasthttpadaptor.NewFastHTTPHandler(handler)(c.Context())
if conn == nil || rw == nil {
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection")
}
return conn, rw, nil
}
func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
userID, ok := c.Locals("userID").(int64)
if !ok || userID == 0 {
h.logger.Error("Invalid user ID in context")
return fiber.NewError(fiber.StatusUnauthorized, "invalid user identification")
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
}
c.Locals("allowed", true)
// Convert *fiber.Ctx to *http.Request
req, err := adaptor.ConvertRequest(c, false)
if err != nil {
h.logger.Error("Failed to convert request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to convert request")
}
return websocket.New(func(conn *websocket.Conn) {
ctx := context.Background()
logger := h.logger.With("userID", userID, "remoteAddr", conn.RemoteAddr())
// Create a net.Conn hijacked from the fasthttp context
netConn, rw, err := hijackHTTP(c)
if err != nil {
h.logger.Error("Failed to hijack connection", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to hijack connection")
}
if err := h.notificationSvc.ConnectWebSocket(ctx, userID, conn); err != nil {
logger.Error("Failed to connect WebSocket", "error", err)
_ = conn.Close()
return
}
// Upgrade the connection using Gorilla's Upgrader
conn, err := ws.Upgrader.Upgrade(rw, req, nil)
if err != nil {
h.logger.Error("WebSocket upgrade failed", "error", err)
netConn.Close()
return fiber.NewError(fiber.StatusInternalServerError, "WebSocket upgrade failed")
}
logger.Info("WebSocket connection established")
client := &ws.Client{
Conn: conn,
RecipientID: userID,
}
defer func() {
h.notificationSvc.DisconnectWebSocket(userID)
logger.Info("WebSocket connection closed")
_ = conn.Close()
}()
h.notificationSvc.Hub.Register <- client
h.logger.Info("WebSocket connection established", "userID", userID)
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
logger.Warn("WebSocket unexpected close", "error", err)
}
break
defer func() {
h.notificationSvc.Hub.Unregister <- client
h.logger.Info("WebSocket connection closed", "userID", userID)
conn.Close()
}()
for {
_, _, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
h.logger.Info("WebSocket closed normally", "userID", userID)
} else {
h.logger.Warn("Unexpected WebSocket closure", "userID", userID, "error", err)
}
break
}
})(c)
}
return nil
}
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
@ -97,18 +144,18 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
userID, ok := c.Locals("userID").(int64)
if !ok || userID == 0 {
h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context")
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
}
// userID, ok := c.Locals("userID").(int64)
// if !ok || userID == 0 {
// h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context")
// return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
// }
switch req.DeliveryScheme {
case domain.NotificationDeliverySchemeSingle:
if req.Reciever == domain.NotificationRecieverSideCustomer && req.RecipientID != userID {
h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "userID", userID, "recipientID", req.RecipientID)
return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient")
}
// if req.Reciever == domain.NotificationRecieverSideCustomer {
// h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "recipientID", req.RecipientID)
// return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient")
// }
notification := &domain.Notification{
ID: "",
@ -177,6 +224,42 @@ func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
}
}
func (h *Handler) GetNotifications(c *fiber.Ctx) error {
limitStr := c.Query("limit", "10")
offsetStr := c.Query("offset", "0")
// Convert limit and offset to integers
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
h.logger.Error("[NotificationSvc.GetNotifications] Invalid limit value", "error", err)
return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value")
}
offset, err := strconv.Atoi(offsetStr)
if err != nil || offset < 0 {
h.logger.Error("[NotificationSvc.GetNotifications] Invalid offset value", "error", err)
return fiber.NewError(fiber.StatusBadRequest, "Invalid offset value")
}
userID, ok := c.Locals("user_id").(int64)
if !ok || userID == 0 {
h.logger.Error("[NotificationSvc.GetNotifications] Invalid user ID in context")
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
}
notifications, err := h.notificationSvc.ListNotifications(context.Background(), userID, limit, offset)
if err != nil {
h.logger.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications")
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"notifications": notifications,
"total_count": len(notifications),
"limit": limit,
"offset": offset,
})
}
func (h *Handler) getAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) {
return h.notificationSvc.ListRecipientIDs(ctx, receiver)
}

View File

@ -44,7 +44,7 @@ func (a *App) authMiddleware(c *fiber.Ctx) error {
}
// Asserting to make sure that there is no company role without a valid company id
if claim.Role != domain.RoleSuperAdmin && !claim.CompanyID.Valid {
if claim.Role != domain.RoleSuperAdmin && claim.Role != domain.RoleCustomer && !claim.CompanyID.Valid {
fmt.Println("Company Role without Company ID")
return fiber.NewError(fiber.StatusInternalServerError, "Company Role without Company ID")
}
@ -71,3 +71,31 @@ func (a *App) CompanyOnly(c *fiber.Ctx) error {
}
return c.Next()
}
func (a *App) WebsocketAuthMiddleware(c *fiber.Ctx) error {
tokenStr := c.Query("token")
if tokenStr == "" {
a.logger.Error("Missing token in query parameter")
return fiber.NewError(fiber.StatusUnauthorized, "Missing token")
}
claim, err := jwtutil.ParseJwt(tokenStr, a.JwtConfig.JwtAccessKey)
if err != nil {
if errors.Is(err, jwtutil.ErrExpiredToken) {
a.logger.Error("Token expired")
return fiber.NewError(fiber.StatusUnauthorized, "Token expired")
}
a.logger.Error("Invalid token", "error", err)
return fiber.NewError(fiber.StatusUnauthorized, "Invalid token")
}
userID := claim.UserId
if userID == 0 {
a.logger.Error("Invalid user ID in token claims")
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID")
}
c.Locals("userID", userID)
a.logger.Info("Authenticated WebSocket connection", "userID", userID)
return c.Next()
}

View File

@ -175,7 +175,8 @@ func (a *App) initAppRoutes() {
a.fiber.Put("/transaction/:id", a.authMiddleware, h.UpdateTransactionVerified)
// Notification Routes
a.fiber.Get("/notifications/ws/connect/:recipientID", h.ConnectSocket)
a.fiber.Get("/ws/connect", a.WebsocketAuthMiddleware, h.ConnectSocket)
a.fiber.Get("/notifications", a.authMiddleware, h.GetNotifications)
a.fiber.Post("/notifications/mark-as-read", h.MarkNotificationAsRead)
a.fiber.Post("/notifications/create", h.CreateAndSendNotification)

View File

@ -0,0 +1,73 @@
package ws
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
type Client struct {
Conn *websocket.Conn
RecipientID int64
}
type NotificationHub struct {
Clients map[*Client]bool
Broadcast chan interface{}
Register chan *Client
Unregister chan *Client
mu sync.Mutex
}
func NewNotificationHub() *NotificationHub {
return &NotificationHub{
Clients: make(map[*Client]bool),
Broadcast: make(chan interface{}, 1000),
Register: make(chan *Client),
Unregister: make(chan *Client),
}
}
func (h *NotificationHub) Run() {
for {
select {
case client := <-h.Register:
h.mu.Lock()
h.Clients[client] = true
h.mu.Unlock()
log.Printf("Client registered: %d", client.RecipientID)
case client := <-h.Unregister:
h.mu.Lock()
if _, ok := h.Clients[client]; ok {
delete(h.Clients, client)
client.Conn.Close()
}
h.mu.Unlock()
log.Printf("Client unregistered: %d", client.RecipientID)
case message := <-h.Broadcast:
h.mu.Lock()
for client := range h.Clients {
if payload, ok := message.(map[string]interface{}); ok {
if recipient, ok := payload["recipient_id"].(int64); ok && recipient == client.RecipientID {
err := client.Conn.WriteJSON(payload)
if err != nil {
delete(h.Clients, client)
client.Conn.Close()
}
}
}
}
h.mu.Unlock()
}
}
}
var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}

View File

@ -1,41 +1,57 @@
include .env
.PHONY: test
test:
@go test ./...
@docker compose up -d test
@docker compose exec test go test ./...
@docker compose stop test
.PHONY: coverage
coverage:
@mkdir -p coverage
@go test -coverprofile=coverage.out ./internal/...
@go tool cover -func=coverage.out -o coverage/coverage.txt
@docker compose up -d test
@docker compose exec test sh -c "go test -coverprofile=coverage.out ./internal/... && go tool cover -func=coverage.out -o coverage/coverage.txt"
@docker cp $(shell docker ps -q -f "name=fortunebet-test-1"):/app/coverage ./ || true
@docker compose stop test
.PHONY: build
build:
@go build -ldflags="-s" -o ./bin/web ./
@docker compose build app
.PHONY: run
run:
@echo "Running Go application"
@go run ./cmd/main.go
@docker compose up -d
.PHONY: stop
stop:
@docker compose down
.PHONY: air
air:
@echo "Running air"
@echo "Running air locally (not in Docker)"
@air -c .air.toml
.PHONY: migrations/up
.PHONY: migrations/new
migrations/new:
@echo 'Creating migration files for DB_URL'
@migrate create -seq -ext=.sql -dir=./db/migrations $(name)
.PHONY: migrations/up
migrations/up:
@echo 'Running up migrations...'
@migrate -path ./db/migrations -database $(DB_URL) up
@docker compose up migrate
.PHONY: swagger
swagger:
@swag init -g cmd/main.go
.PHONY: db-up
db-up:
docker compose -f compose.db.yaml up
@docker compose up -d postgres
.PHONY: db-down
db-down:
docker compose -f compose.db.yaml down
@docker compose down
.PHONY: sqlc-gen
sqlc-gen:
@sqlc generate
@sqlc generate