diff --git a/.gitignore b/.gitignore index 036c1c6..d96cd29 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ build logs/ app_logs/ backup/ - +reports/ diff --git a/.vscode/settings.json b/.vscode/settings.json index cb34380..2e648fd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,12 @@ ], "cSpell.enabledFileTypes": { "sql": false - } + }, + "workbench.editor.customLabels.enabled": true, + "workbench.editor.customLabels.patterns": { + "**/internal/services/**/service.go": "${dirname}.service", + "**/internal/services/**/*.go": "${filename}.${dirname}.service", + "**/internal/domain/**/*.go": "${filename}.${dirname}", + "**/internal/repository/**/*.go": "${filename}.repo", + }, } \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 830e0ac..5f45894 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -42,7 +42,8 @@ import ( issuereporting "github.com/SamuelTariku/FortuneBet-Backend/internal/services/issue_reporting" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/kafka" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/messenger" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" @@ -104,13 +105,15 @@ func main() { // Initialize services settingSvc := settings.NewService(store) + messengerSvc := messenger.NewService(settingSvc, cfg) + authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) - userSvc := user.NewService(store, store, cfg) + userSvc := user.NewService(store, store, messengerSvc, cfg) eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(store, cfg, logger) notificationRepo := repository.NewNotificationRepository(store) virtuaGamesRepo := repository.NewVirtualGameRepository(store) - notificationSvc := notificationservice.New(notificationRepo, logger, cfg) + notificationSvc := notificationservice.New(notificationRepo, domain.MongoDBLogger, logger, cfg, messengerSvc, userSvc) var notificatioStore notificationservice.NotificationStore // var userStore user.UserStore @@ -121,6 +124,8 @@ func main() { notificatioStore, // userStore, notificationSvc, + userSvc, + domain.MongoDBLogger, logger, kafka.NewProducer([]string{"localhost:9092"}, "wallet-events"), ) @@ -129,7 +134,7 @@ func main() { companySvc := company.NewService(store) leagueSvc := league.New(store) ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger, *settingSvc, notificationSvc) - betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, *companySvc, *settingSvc, notificationSvc, logger, domain.MongoDBLogger) + betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, *companySvc, *settingSvc, *userSvc, notificationSvc, logger, domain.MongoDBLogger) resultSvc := result.NewService(store, cfg, logger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc) bonusSvc := bonus.NewService(store) referalRepo := repository.NewReferralRepository(store) diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 2332e39..d854608 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -79,4 +79,6 @@ DROP TABLE IF EXISTS events; DROP TABLE IF EXISTS leagues; DROP TABLE IF EXISTS teams; DROP TABLE IF EXISTS settings; +DROP TABLE IF EXISTS bonus; +DROP TABLE IF EXISTS flags; -- DELETE FROM wallet_transfer; \ No newline at end of file diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 5a74346..6892944 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -133,6 +133,7 @@ CREATE TABLE IF NOT EXISTS wallets ( is_bettable BOOLEAN NOT NULL, is_transferable BOOLEAN NOT NULL, user_id BIGINT NOT NULL, + type VARCHAR(255) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -208,7 +209,7 @@ CREATE TABLE IF NOT EXISTS branches ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, location TEXT NOT NULL, - profit_percent REAL NOt NULL, + profit_percent REAL NOT NULL, is_active BOOLEAN NOT NULL DEFAULT false, wallet_id BIGINT NOT NULL, branch_manager_id BIGINT NOT NULL, @@ -216,7 +217,11 @@ CREATE TABLE IF NOT EXISTS branches ( is_self_owned BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(wallet_id) + UNIQUE(wallet_id), + CONSTRAINT profit_percentage_check CHECK ( + profit_percent >= 0 + AND profit_percent < 1 + ) ); CREATE TABLE IF NOT EXISTS branch_operations ( id BIGSERIAL PRIMARY KEY, @@ -258,7 +263,8 @@ CREATE TABLE events ( status TEXT, fetched_at TIMESTAMP DEFAULT now(), source TEXT DEFAULT 'b365api', - flagged BOOLEAN NOT NULL DEFAULT false + is_featured BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE ); CREATE TABLE odds ( id SERIAL PRIMARY KEY, @@ -289,7 +295,11 @@ CREATE TABLE companies ( deducted_percentage REAL NOT NULL, is_active BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT deducted_percentage_check CHECK ( + deducted_percentage >= 0 + AND deducted_percentage < 1 + ) ); CREATE TABLE leagues ( id BIGINT PRIMARY KEY, @@ -319,6 +329,25 @@ CREATE TABLE bonus ( multiplier REAL NOT NULL, balance_cap BIGINT NOT NULL DEFAULT 0 ); +CREATE TABLE flags ( + id BIGSERIAL PRIMARY KEY, + bet_id BIGINT REFERENCES bets(id) ON DELETE CASCADE, + odd_id BIGINT REFERENCES odds(id), + reason TEXT, + flagged_at TIMESTAMP DEFAULT NOW(), + resolved BOOLEAN DEFAULT FALSE, + -- either bet or odd is flagged (not at the same time) + CHECK ( + ( + bet_id IS NOT NULL + AND odd_id IS NULL + ) + OR ( + bet_id IS NULL + AND odd_id IS NOT NULL + ) + ) +); -- Views CREATE VIEW companies_details AS SELECT companies.*, diff --git a/db/migrations/000007_setting_data.up.sql b/db/migrations/000007_setting_data.up.sql index 0bee556..8b96ee4 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -1,11 +1,22 @@ -- Settings Initial Data INSERT INTO settings (key, value) +<<<<<<< HEAD VALUES ('max_number_of_outcomes', '30'), +======= +VALUES ('sms_provider', '30'), +('max_number_of_outcomes', '30'), +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f ('bet_amount_limit', '100000'), ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), ('amount_for_bet_referral', '1000000'), +<<<<<<< HEAD ('cashback_amount_cap', '1000') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; +======= + ('cashback_amount_cap', '1000') ON CONFLICT (key) DO +UPDATE +SET value = EXCLUDED.value; +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f diff --git a/db/migrations/000009_location_data.up.sql b/db/migrations/000009_location_data.up.sql index 3d9c67d..156831d 100644 --- a/db/migrations/000009_location_data.up.sql +++ b/db/migrations/000009_location_data.up.sql @@ -45,8 +45,7 @@ VALUES ('addis_ababa', 'Addis Ababa'), ('meki', 'Meki'), ('negele_borana', 'Negele Borana'), ('alaba_kulito', 'Alaba Kulito'), - ('alamata 14,', 'Alamata 14,'), - ('030', '030'), + ('alamata,', 'Alamata,'), ('chiro', 'Chiro'), ('tepi', 'Tepi'), ('durame', 'Durame'), diff --git a/db/query/bet.sql b/db/query/bet.sql index 8cca28e..47018e1 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -101,11 +101,19 @@ WHERE (event_id = $1) SELECT * FROM bet_outcomes WHERE bet_id = $1; --- name: GetBetCount :one +-- name: GetBetOutcomeCountByOddID :one +SELECT COUNT(*) +FROM bet_outcomes +WHERE odd_id = $1; +-- name: GetBetCountByUserID :one SELECT COUNT(*) FROM bets WHERE user_id = $1 AND outcomes_hash = $2; +-- name: GetBetCountByOutcomesHash :one +SELECT COUNT(*) +FROM bets +WHERE outcomes_hash = $1; -- name: UpdateCashOut :exec UPDATE bets SET cashed_out = $2, diff --git a/db/query/branch.sql b/db/query/branch.sql index 4c38e0c..1e09f40 100644 --- a/db/query/branch.sql +++ b/db/query/branch.sql @@ -5,9 +5,10 @@ INSERT INTO branches ( wallet_id, branch_manager_id, company_id, - is_self_owned + is_self_owned, + profit_percent ) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: CreateSupportedOperation :one INSERT INTO supported_operations (name, description) @@ -88,6 +89,7 @@ SET name = COALESCE(sqlc.narg(name), name), company_id = COALESCE(sqlc.narg(company_id), company_id), is_self_owned = COALESCE(sqlc.narg(is_self_owned), is_self_owned), is_active = COALESCE(sqlc.narg(is_active), is_active), + profit_percent = COALESCE(sqlc.narg(profit_percent), profit_percent), updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *; diff --git a/db/query/events.sql b/db/query/events.sql index 14750c8..b88f6d8 100644 --- a/db/query/events.sql +++ b/db/query/events.sql @@ -157,6 +157,11 @@ WHERE is_live = false events.sport_id = sqlc.narg('sport_id') OR sqlc.narg('sport_id') IS NULL ) + AND ( + match_name ILIKE '%' || sqlc.narg('query') || '%' + OR league_name ILIKE '%' || sqlc.narg('query') || '%' + OR sqlc.narg('query') IS NULL + ) AND ( start_time < sqlc.narg('last_start_time') OR sqlc.narg('last_start_time') IS NULL @@ -170,8 +175,8 @@ WHERE is_live = false OR sqlc.narg('country_code') IS NULL ) AND ( - flagged = sqlc.narg('flagged') - OR sqlc.narg('flagged') IS NULL + events.is_featured = sqlc.narg('is_featured') + OR sqlc.narg('is_featured') IS NULL ); -- name: GetPaginatedUpcomingEvents :many SELECT events.*, @@ -189,6 +194,11 @@ WHERE start_time > now() events.sport_id = sqlc.narg('sport_id') OR sqlc.narg('sport_id') IS NULL ) + AND ( + match_name ILIKE '%' || sqlc.narg('query') || '%' + OR league_name ILIKE '%' || sqlc.narg('query') || '%' + OR sqlc.narg('query') IS NULL + ) AND ( start_time < sqlc.narg('last_start_time') OR sqlc.narg('last_start_time') IS NULL @@ -202,8 +212,8 @@ WHERE start_time > now() OR sqlc.narg('country_code') IS NULL ) AND ( - flagged = sqlc.narg('flagged') - OR sqlc.narg('flagged') IS NULL + events.is_featured = sqlc.narg('is_featured') + OR sqlc.narg('is_featured') IS NULL ) ORDER BY start_time ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); @@ -219,9 +229,9 @@ UPDATE events SET score = $1, status = $2 WHERE id = $3; --- name: UpdateFlagged :exec +-- name: UpdateFeatured :exec UPDATE events -SET flagged = $1 +SET is_featured = $1 WHERE id = $2; -- name: DeleteEvent :exec DELETE FROM events diff --git a/db/query/flags.sql b/db/query/flags.sql new file mode 100644 index 0000000..60093c5 --- /dev/null +++ b/db/query/flags.sql @@ -0,0 +1,8 @@ +-- name: CreateFlag :one +INSERT INTO flags ( + bet_id, + odd_id, + reason +) VALUES ( + $1, $2, $3 +) RETURNING *; \ No newline at end of file diff --git a/db/query/leagues.sql b/db/query/leagues.sql index 7aa7623..368da67 100644 --- a/db/query/leagues.sql +++ b/db/query/leagues.sql @@ -41,6 +41,8 @@ WHERE ( is_featured = sqlc.narg('is_featured') OR sqlc.narg('is_featured') IS NULL ) +ORDER BY is_featured DESC, + name ASC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetFeaturedLeagues :many SELECT id, diff --git a/db/query/location.sql b/db/query/location.sql new file mode 100644 index 0000000..01d117d --- /dev/null +++ b/db/query/location.sql @@ -0,0 +1,7 @@ +-- name: GetAllBranchLocations :many +SELECT * +FROM branch_locations +WHERE ( + value ILIKE '%' || sqlc.narg('query') || '%' + OR sqlc.narg('query') IS NULL + ); \ No newline at end of file diff --git a/db/query/settings.sql b/db/query/settings.sql index 6400096..d0f4482 100644 --- a/db/query/settings.sql +++ b/db/query/settings.sql @@ -10,26 +10,4 @@ INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, CURRENT_TIMESTAMP) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value -RETURNING *; - --- name: SetInitialData :exec -INSERT INTO settings (key, value) -VALUES ('max_number_of_outcomes', '30') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value; -INSERT INTO settings (key, value) -VALUES ('bet_amount_limit', '100000') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value; -INSERT INTO settings (key, value) -VALUES ('daily_ticket_limit', '50') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value; -INSERT INTO settings (key, value) -VALUES ('total_winnings_limit', '1000000') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value; -INSERT INTO settings (key, value) -VALUES ('amount_for_bet_referral', '1000000') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value; \ No newline at end of file +RETURNING *; \ No newline at end of file diff --git a/db/query/user.sql b/db/query/user.sql index 71d935c..d7eae90 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -192,4 +192,9 @@ SET password = $1, WHERE ( email = $2 OR phone_number = $3 - ); \ No newline at end of file + ); +-- name: GetAdminByCompanyID :one +SELECT users.* +FROM companies + JOIN users ON companies.admin_id = users.id +where companies.id = $1; \ No newline at end of file diff --git a/db/query/wallet.sql b/db/query/wallet.sql index d22effe..a6c9998 100644 --- a/db/query/wallet.sql +++ b/db/query/wallet.sql @@ -3,9 +3,10 @@ INSERT INTO wallets ( is_withdraw, is_bettable, is_transferable, - user_id + user_id, + type ) -VALUES ($1, $2, $3, $4) +VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: CreateCustomerWallet :one INSERT INTO customer_wallets ( diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index cae3d8b..31ca511 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -282,20 +282,33 @@ func (q *Queries) GetBetByUserID(ctx context.Context, userID int64) ([]BetWithOu return items, nil } -const GetBetCount = `-- name: GetBetCount :one +const GetBetCountByOutcomesHash = `-- name: GetBetCountByOutcomesHash :one +SELECT COUNT(*) +FROM bets +WHERE outcomes_hash = $1 +` + +func (q *Queries) GetBetCountByOutcomesHash(ctx context.Context, outcomesHash string) (int64, error) { + row := q.db.QueryRow(ctx, GetBetCountByOutcomesHash, outcomesHash) + var count int64 + err := row.Scan(&count) + return count, err +} + +const GetBetCountByUserID = `-- name: GetBetCountByUserID :one SELECT COUNT(*) FROM bets WHERE user_id = $1 AND outcomes_hash = $2 ` -type GetBetCountParams struct { +type GetBetCountByUserIDParams struct { UserID int64 `json:"user_id"` OutcomesHash string `json:"outcomes_hash"` } -func (q *Queries) GetBetCount(ctx context.Context, arg GetBetCountParams) (int64, error) { - row := q.db.QueryRow(ctx, GetBetCount, arg.UserID, arg.OutcomesHash) +func (q *Queries) GetBetCountByUserID(ctx context.Context, arg GetBetCountByUserIDParams) (int64, error) { + row := q.db.QueryRow(ctx, GetBetCountByUserID, arg.UserID, arg.OutcomesHash) var count int64 err := row.Scan(&count) return count, err @@ -397,6 +410,19 @@ func (q *Queries) GetBetOutcomeByEventID(ctx context.Context, arg GetBetOutcomeB return items, nil } +const GetBetOutcomeCountByOddID = `-- name: GetBetOutcomeCountByOddID :one +SELECT COUNT(*) +FROM bet_outcomes +WHERE odd_id = $1 +` + +func (q *Queries) GetBetOutcomeCountByOddID(ctx context.Context, oddID int64) (int64, error) { + row := q.db.QueryRow(ctx, GetBetOutcomeCountByOddID, oddID) + var count int64 + err := row.Scan(&count) + return count, err +} + const GetBetsForCashback = `-- name: GetBetsForCashback :many SELECT id, amount, total_odds, status, user_id, is_shop_bet, cashed_out, outcomes_hash, fast_code, processed, created_at, updated_at, full_name, phone_number, outcomes FROM bet_with_outcomes diff --git a/gen/db/branch.sql.go b/gen/db/branch.sql.go index 88a96db..a9a57b8 100644 --- a/gen/db/branch.sql.go +++ b/gen/db/branch.sql.go @@ -18,19 +18,21 @@ INSERT INTO branches ( wallet_id, branch_manager_id, company_id, - is_self_owned + is_self_owned, + profit_percent ) -VALUES ($1, $2, $3, $4, $5, $6) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, name, location, profit_percent, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at ` type CreateBranchParams struct { - Name string `json:"name"` - Location string `json:"location"` - WalletID int64 `json:"wallet_id"` - BranchManagerID int64 `json:"branch_manager_id"` - CompanyID int64 `json:"company_id"` - IsSelfOwned bool `json:"is_self_owned"` + Name string `json:"name"` + Location string `json:"location"` + WalletID int64 `json:"wallet_id"` + BranchManagerID int64 `json:"branch_manager_id"` + CompanyID int64 `json:"company_id"` + IsSelfOwned bool `json:"is_self_owned"` + ProfitPercent float32 `json:"profit_percent"` } func (q *Queries) CreateBranch(ctx context.Context, arg CreateBranchParams) (Branch, error) { @@ -41,6 +43,7 @@ func (q *Queries) CreateBranch(ctx context.Context, arg CreateBranchParams) (Bra arg.BranchManagerID, arg.CompanyID, arg.IsSelfOwned, + arg.ProfitPercent, ) var i Branch err := row.Scan( @@ -498,19 +501,21 @@ SET name = COALESCE($2, name), company_id = COALESCE($5, company_id), is_self_owned = COALESCE($6, is_self_owned), is_active = COALESCE($7, is_active), + profit_percent = COALESCE($8, profit_percent), updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING id, name, location, profit_percent, is_active, wallet_id, branch_manager_id, company_id, is_self_owned, created_at, updated_at ` type UpdateBranchParams struct { - ID int64 `json:"id"` - Name pgtype.Text `json:"name"` - Location pgtype.Text `json:"location"` - BranchManagerID pgtype.Int8 `json:"branch_manager_id"` - CompanyID pgtype.Int8 `json:"company_id"` - IsSelfOwned pgtype.Bool `json:"is_self_owned"` - IsActive pgtype.Bool `json:"is_active"` + ID int64 `json:"id"` + Name pgtype.Text `json:"name"` + Location pgtype.Text `json:"location"` + BranchManagerID pgtype.Int8 `json:"branch_manager_id"` + CompanyID pgtype.Int8 `json:"company_id"` + IsSelfOwned pgtype.Bool `json:"is_self_owned"` + IsActive pgtype.Bool `json:"is_active"` + ProfitPercent pgtype.Float4 `json:"profit_percent"` } func (q *Queries) UpdateBranch(ctx context.Context, arg UpdateBranchParams) (Branch, error) { @@ -522,6 +527,7 @@ func (q *Queries) UpdateBranch(ctx context.Context, arg UpdateBranchParams) (Bra arg.CompanyID, arg.IsSelfOwned, arg.IsActive, + arg.ProfitPercent, ) var i Branch err := row.Scan( diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go index 9e11418..101c705 100644 --- a/gen/db/events.sql.go +++ b/gen/db/events.sql.go @@ -22,7 +22,7 @@ func (q *Queries) DeleteEvent(ctx context.Context, id string) error { } const GetAllUpcomingEvents = `-- name: GetAllUpcomingEvents :many -SELECT 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, fetched_at, source, flagged +SELECT 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, fetched_at, source, is_featured, is_active FROM events WHERE start_time > now() AND is_live = false @@ -62,7 +62,8 @@ func (q *Queries) GetAllUpcomingEvents(ctx context.Context) ([]Event, error) { &i.Status, &i.FetchedAt, &i.Source, - &i.Flagged, + &i.IsFeatured, + &i.IsActive, ); err != nil { return nil, err } @@ -75,7 +76,7 @@ func (q *Queries) GetAllUpcomingEvents(ctx context.Context) ([]Event, error) { } const GetExpiredUpcomingEvents = `-- name: GetExpiredUpcomingEvents :many -SELECT events.id, events.sport_id, events.match_name, events.home_team, events.away_team, events.home_team_id, events.away_team_id, events.home_kit_image, events.away_kit_image, events.league_id, events.league_name, events.league_cc, events.start_time, events.score, events.match_minute, events.timer_status, events.added_time, events.match_period, events.is_live, events.status, events.fetched_at, events.source, events.flagged, +SELECT events.id, events.sport_id, events.match_name, events.home_team, events.away_team, events.home_team_id, events.away_team_id, events.home_kit_image, events.away_kit_image, events.league_id, events.league_name, events.league_cc, events.start_time, events.score, events.match_minute, events.timer_status, events.added_time, events.match_period, events.is_live, events.status, events.fetched_at, events.source, events.is_featured, events.is_active, leagues.country_code as league_cc FROM events LEFT JOIN leagues ON leagues.id = league_id @@ -110,7 +111,8 @@ type GetExpiredUpcomingEventsRow struct { Status pgtype.Text `json:"status"` FetchedAt pgtype.Timestamp `json:"fetched_at"` Source pgtype.Text `json:"source"` - Flagged bool `json:"flagged"` + IsFeatured bool `json:"is_featured"` + IsActive bool `json:"is_active"` LeagueCc_2 pgtype.Text `json:"league_cc_2"` } @@ -146,7 +148,8 @@ func (q *Queries) GetExpiredUpcomingEvents(ctx context.Context, status pgtype.Te &i.Status, &i.FetchedAt, &i.Source, - &i.Flagged, + &i.IsFeatured, + &i.IsActive, &i.LeagueCc_2, ); err != nil { return nil, err @@ -160,7 +163,7 @@ func (q *Queries) GetExpiredUpcomingEvents(ctx context.Context, status pgtype.Te } const GetPaginatedUpcomingEvents = `-- name: GetPaginatedUpcomingEvents :many -SELECT events.id, events.sport_id, events.match_name, events.home_team, events.away_team, events.home_team_id, events.away_team_id, events.home_kit_image, events.away_kit_image, events.league_id, events.league_name, events.league_cc, events.start_time, events.score, events.match_minute, events.timer_status, events.added_time, events.match_period, events.is_live, events.status, events.fetched_at, events.source, events.flagged, +SELECT events.id, events.sport_id, events.match_name, events.home_team, events.away_team, events.home_team_id, events.away_team_id, events.home_kit_image, events.away_kit_image, events.league_id, events.league_name, events.league_cc, events.start_time, events.score, events.match_minute, events.timer_status, events.added_time, events.match_period, events.is_live, events.status, events.fetched_at, events.source, events.is_featured, events.is_active, leagues.country_code as league_cc FROM events LEFT JOIN leagues ON leagues.id = league_id @@ -176,32 +179,38 @@ WHERE start_time > now() OR $2 IS NULL ) AND ( - start_time < $3 + match_name ILIKE '%' || $3 || '%' + OR league_name ILIKE '%' || $3 || '%' OR $3 IS NULL ) AND ( - start_time > $4 + start_time < $4 OR $4 IS NULL ) AND ( - leagues.country_code = $5 + start_time > $5 OR $5 IS NULL ) AND ( - flagged = $6 + leagues.country_code = $6 OR $6 IS NULL ) + AND ( + events.is_featured = $7 + OR $7 IS NULL + ) ORDER BY start_time ASC -LIMIT $8 OFFSET $7 +LIMIT $9 OFFSET $8 ` type GetPaginatedUpcomingEventsParams struct { LeagueID pgtype.Int4 `json:"league_id"` SportID pgtype.Int4 `json:"sport_id"` + Query pgtype.Text `json:"query"` LastStartTime pgtype.Timestamp `json:"last_start_time"` FirstStartTime pgtype.Timestamp `json:"first_start_time"` CountryCode pgtype.Text `json:"country_code"` - Flagged pgtype.Bool `json:"flagged"` + IsFeatured pgtype.Bool `json:"is_featured"` Offset pgtype.Int4 `json:"offset"` Limit pgtype.Int4 `json:"limit"` } @@ -229,7 +238,8 @@ type GetPaginatedUpcomingEventsRow struct { Status pgtype.Text `json:"status"` FetchedAt pgtype.Timestamp `json:"fetched_at"` Source pgtype.Text `json:"source"` - Flagged bool `json:"flagged"` + IsFeatured bool `json:"is_featured"` + IsActive bool `json:"is_active"` LeagueCc_2 pgtype.Text `json:"league_cc_2"` } @@ -237,10 +247,11 @@ func (q *Queries) GetPaginatedUpcomingEvents(ctx context.Context, arg GetPaginat rows, err := q.db.Query(ctx, GetPaginatedUpcomingEvents, arg.LeagueID, arg.SportID, + arg.Query, arg.LastStartTime, arg.FirstStartTime, arg.CountryCode, - arg.Flagged, + arg.IsFeatured, arg.Offset, arg.Limit, ) @@ -274,7 +285,8 @@ func (q *Queries) GetPaginatedUpcomingEvents(ctx context.Context, arg GetPaginat &i.Status, &i.FetchedAt, &i.Source, - &i.Flagged, + &i.IsFeatured, + &i.IsActive, &i.LeagueCc_2, ); err != nil { return nil, err @@ -302,40 +314,47 @@ WHERE is_live = false OR $2 IS NULL ) AND ( - start_time < $3 + match_name ILIKE '%' || $3 || '%' + OR league_name ILIKE '%' || $3 || '%' OR $3 IS NULL ) AND ( - start_time > $4 + start_time < $4 OR $4 IS NULL ) AND ( - leagues.country_code = $5 + start_time > $5 OR $5 IS NULL ) AND ( - flagged = $6 + leagues.country_code = $6 OR $6 IS NULL ) + AND ( + events.is_featured = $7 + OR $7 IS NULL + ) ` type GetTotalEventsParams struct { LeagueID pgtype.Int4 `json:"league_id"` SportID pgtype.Int4 `json:"sport_id"` + Query pgtype.Text `json:"query"` LastStartTime pgtype.Timestamp `json:"last_start_time"` FirstStartTime pgtype.Timestamp `json:"first_start_time"` CountryCode pgtype.Text `json:"country_code"` - Flagged pgtype.Bool `json:"flagged"` + IsFeatured pgtype.Bool `json:"is_featured"` } func (q *Queries) GetTotalEvents(ctx context.Context, arg GetTotalEventsParams) (int64, error) { row := q.db.QueryRow(ctx, GetTotalEvents, arg.LeagueID, arg.SportID, + arg.Query, arg.LastStartTime, arg.FirstStartTime, arg.CountryCode, - arg.Flagged, + arg.IsFeatured, ) var count int64 err := row.Scan(&count) @@ -343,7 +362,7 @@ func (q *Queries) GetTotalEvents(ctx context.Context, arg GetTotalEventsParams) } const GetUpcomingByID = `-- name: GetUpcomingByID :one -SELECT 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, fetched_at, source, flagged +SELECT 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, fetched_at, source, is_featured, is_active FROM events WHERE id = $1 AND is_live = false @@ -377,7 +396,8 @@ func (q *Queries) GetUpcomingByID(ctx context.Context, id string) (Event, error) &i.Status, &i.FetchedAt, &i.Source, - &i.Flagged, + &i.IsFeatured, + &i.IsActive, ) return i, err } @@ -623,19 +643,19 @@ func (q *Queries) ListLiveEvents(ctx context.Context) ([]string, error) { return items, nil } -const UpdateFlagged = `-- name: UpdateFlagged :exec +const UpdateFeatured = `-- name: UpdateFeatured :exec UPDATE events -SET flagged = $1 +SET is_featured = $1 WHERE id = $2 ` -type UpdateFlaggedParams struct { - Flagged bool `json:"flagged"` - ID string `json:"id"` +type UpdateFeaturedParams struct { + IsFeatured bool `json:"is_featured"` + ID string `json:"id"` } -func (q *Queries) UpdateFlagged(ctx context.Context, arg UpdateFlaggedParams) error { - _, err := q.db.Exec(ctx, UpdateFlagged, arg.Flagged, arg.ID) +func (q *Queries) UpdateFeatured(ctx context.Context, arg UpdateFeaturedParams) error { + _, err := q.db.Exec(ctx, UpdateFeatured, arg.IsFeatured, arg.ID) return err } diff --git a/gen/db/flags.sql.go b/gen/db/flags.sql.go new file mode 100644 index 0000000..17b406e --- /dev/null +++ b/gen/db/flags.sql.go @@ -0,0 +1,42 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: flags.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const CreateFlag = `-- name: CreateFlag :one +INSERT INTO flags ( + bet_id, + odd_id, + reason +) VALUES ( + $1, $2, $3 +) RETURNING id, bet_id, odd_id, reason, flagged_at, resolved +` + +type CreateFlagParams struct { + BetID pgtype.Int8 `json:"bet_id"` + OddID pgtype.Int8 `json:"odd_id"` + Reason pgtype.Text `json:"reason"` +} + +func (q *Queries) CreateFlag(ctx context.Context, arg CreateFlagParams) (Flag, error) { + row := q.db.QueryRow(ctx, CreateFlag, arg.BetID, arg.OddID, arg.Reason) + var i Flag + err := row.Scan( + &i.ID, + &i.BetID, + &i.OddID, + &i.Reason, + &i.FlaggedAt, + &i.Resolved, + ) + return i, err +} diff --git a/gen/db/leagues.sql.go b/gen/db/leagues.sql.go index fa5da4c..143f6ca 100644 --- a/gen/db/leagues.sql.go +++ b/gen/db/leagues.sql.go @@ -52,6 +52,8 @@ WHERE ( is_featured = $4 OR $4 IS NULL ) +ORDER BY is_featured DESC, + name ASC LIMIT $6 OFFSET $5 ` diff --git a/gen/db/location.sql.go b/gen/db/location.sql.go new file mode 100644 index 0000000..008aa61 --- /dev/null +++ b/gen/db/location.sql.go @@ -0,0 +1,41 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: location.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetAllBranchLocations = `-- name: GetAllBranchLocations :many +SELECT key, value +FROM branch_locations +WHERE ( + value ILIKE '%' || $1 || '%' + OR $1 IS NULL + ) +` + +func (q *Queries) GetAllBranchLocations(ctx context.Context, query pgtype.Text) ([]BranchLocation, error) { + rows, err := q.db.Query(ctx, GetAllBranchLocations, query) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BranchLocation + for rows.Next() { + var i BranchLocation + if err := rows.Scan(&i.Key, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/models.go b/gen/db/models.go index cccf340..575526a 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -257,7 +257,8 @@ type Event struct { Status pgtype.Text `json:"status"` FetchedAt pgtype.Timestamp `json:"fetched_at"` Source pgtype.Text `json:"source"` - Flagged bool `json:"flagged"` + IsFeatured bool `json:"is_featured"` + IsActive bool `json:"is_active"` } type ExchangeRate struct { @@ -276,6 +277,15 @@ type FavoriteGame struct { CreatedAt pgtype.Timestamp `json:"created_at"` } +type Flag struct { + ID int64 `json:"id"` + BetID pgtype.Int8 `json:"bet_id"` + OddID pgtype.Int8 `json:"odd_id"` + Reason pgtype.Text `json:"reason"` + FlaggedAt pgtype.Timestamp `json:"flagged_at"` + Resolved pgtype.Bool `json:"resolved"` +} + type League struct { ID int64 `json:"id"` Name string `json:"name"` @@ -664,6 +674,7 @@ type Wallet struct { IsBettable bool `json:"is_bettable"` IsTransferable bool `json:"is_transferable"` UserID int64 `json:"user_id"` + Type string `json:"type"` IsActive bool `json:"is_active"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` diff --git a/gen/db/settings.sql.go b/gen/db/settings.sql.go index 23de72c..d842661 100644 --- a/gen/db/settings.sql.go +++ b/gen/db/settings.sql.go @@ -81,15 +81,3 @@ func (q *Queries) SaveSetting(ctx context.Context, arg SaveSettingParams) (Setti ) return i, err } - -const SetInitialData = `-- name: SetInitialData :exec -INSERT INTO settings (key, value) -VALUES ('max_number_of_outcomes', '30') ON CONFLICT (key) DO -UPDATE -SET value = EXCLUDED.value -` - -func (q *Queries) SetInitialData(ctx context.Context) error { - _, err := q.db.Exec(ctx, SetInitialData) - return err -} diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 1daaec0..48faa10 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -159,6 +159,37 @@ func (q *Queries) DeleteUser(ctx context.Context, id int64) error { return err } +const GetAdminByCompanyID = `-- name: GetAdminByCompanyID :one +SELECT users.id, users.first_name, users.last_name, users.email, users.phone_number, users.role, users.password, users.email_verified, users.phone_verified, users.created_at, users.updated_at, users.company_id, users.suspended_at, users.suspended, users.referral_code, users.referred_by +FROM companies + JOIN users ON companies.admin_id = users.id +where companies.id = $1 +` + +func (q *Queries) GetAdminByCompanyID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRow(ctx, GetAdminByCompanyID, id) + var i User + err := row.Scan( + &i.ID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.PhoneNumber, + &i.Role, + &i.Password, + &i.EmailVerified, + &i.PhoneVerified, + &i.CreatedAt, + &i.UpdatedAt, + &i.CompanyID, + &i.SuspendedAt, + &i.Suspended, + &i.ReferralCode, + &i.ReferredBy, + ) + return i, err +} + const GetAllUsers = `-- name: GetAllUsers :many SELECT id, first_name, diff --git a/gen/db/wallet.sql.go b/gen/db/wallet.sql.go index 4b94209..1cb7387 100644 --- a/gen/db/wallet.sql.go +++ b/gen/db/wallet.sql.go @@ -46,17 +46,19 @@ INSERT INTO wallets ( is_withdraw, is_bettable, is_transferable, - user_id + user_id, + type ) -VALUES ($1, $2, $3, $4) -RETURNING id, balance, is_withdraw, is_bettable, is_transferable, user_id, is_active, created_at, updated_at, currency, bonus_balance, cash_balance +VALUES ($1, $2, $3, $4, $5) +RETURNING id, balance, is_withdraw, is_bettable, is_transferable, user_id, type, is_active, created_at, updated_at, currency, bonus_balance, cash_balance ` type CreateWalletParams struct { - IsWithdraw bool `json:"is_withdraw"` - IsBettable bool `json:"is_bettable"` - IsTransferable bool `json:"is_transferable"` - UserID int64 `json:"user_id"` + IsWithdraw bool `json:"is_withdraw"` + IsBettable bool `json:"is_bettable"` + IsTransferable bool `json:"is_transferable"` + UserID int64 `json:"user_id"` + Type string `json:"type"` } func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (Wallet, error) { @@ -65,6 +67,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (Wal arg.IsBettable, arg.IsTransferable, arg.UserID, + arg.Type, ) var i Wallet err := row.Scan( @@ -74,6 +77,7 @@ func (q *Queries) CreateWallet(ctx context.Context, arg CreateWalletParams) (Wal &i.IsBettable, &i.IsTransferable, &i.UserID, + &i.Type, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -184,7 +188,7 @@ func (q *Queries) GetAllCustomerWallet(ctx context.Context) ([]CustomerWalletDet } const GetAllWallets = `-- name: GetAllWallets :many -SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, is_active, created_at, updated_at, currency, bonus_balance, cash_balance +SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, type, is_active, created_at, updated_at, currency, bonus_balance, cash_balance FROM wallets ` @@ -204,6 +208,7 @@ func (q *Queries) GetAllWallets(ctx context.Context) ([]Wallet, error) { &i.IsBettable, &i.IsTransferable, &i.UserID, + &i.Type, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -314,7 +319,7 @@ func (q *Queries) GetCustomerWallet(ctx context.Context, customerID int64) (Cust } const GetWalletByID = `-- name: GetWalletByID :one -SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, is_active, created_at, updated_at, currency, bonus_balance, cash_balance +SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, type, is_active, created_at, updated_at, currency, bonus_balance, cash_balance FROM wallets WHERE id = $1 ` @@ -329,6 +334,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (Wallet, error) { &i.IsBettable, &i.IsTransferable, &i.UserID, + &i.Type, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, @@ -340,7 +346,7 @@ func (q *Queries) GetWalletByID(ctx context.Context, id int64) (Wallet, error) { } const GetWalletByUserID = `-- name: GetWalletByUserID :many -SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, is_active, created_at, updated_at, currency, bonus_balance, cash_balance +SELECT id, balance, is_withdraw, is_bettable, is_transferable, user_id, type, is_active, created_at, updated_at, currency, bonus_balance, cash_balance FROM wallets WHERE user_id = $1 ` @@ -361,6 +367,7 @@ func (q *Queries) GetWalletByUserID(ctx context.Context, userID int64) ([]Wallet &i.IsBettable, &i.IsTransferable, &i.UserID, + &i.Type, &i.IsActive, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/domain/bet.go b/internal/domain/bet.go index a3f0c2b..bc6aae0 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -61,6 +61,15 @@ type BetFilter struct { CreatedAfter ValidTime } +type Flag struct { + ID int64 + BetID int64 + OddID int64 + Reason string + FlaggedAt time.Time + Resolved bool +} + type GetBet struct { ID int64 Amount Currency @@ -93,16 +102,22 @@ type CreateBetOutcomeReq struct { } type CreateBetReq struct { - Outcomes []CreateBetOutcomeReq `json:"outcomes" validate:"required"` - Amount float32 `json:"amount" validate:"required,gt=0" example:"100.0"` - BranchID *int64 `json:"branch_id,omitempty" validate:"required" example:"1"` + Outcomes []CreateBetOutcomeReq `json:"outcomes" validate:"required"` + Amount float32 `json:"amount" validate:"required,gt=0" example:"100.0"` + BranchID *int64 `json:"branch_id,omitempty" example:"1"` } -type CreateBetWithFastCodeReq struct { +type CreateBetWithFastCodeReq struct { FastCode string `json:"fast_code"` Amount float32 `json:"amount"` BranchID *int64 `json:"branch_id"` -} +} + +type CreateFlagReq struct { + BetID int64 + OddID int64 + Reason string +} type RandomBetReq struct { BranchID int64 `json:"branch_id" validate:"required" example:"1"` @@ -117,6 +132,7 @@ type CreateBetRes struct { UserID int64 `json:"user_id" example:"2"` IsShopBet bool `json:"is_shop_bet" example:"false"` CreatedNumber int64 `json:"created_number" example:"2"` + FastCode string `json:"fast_code"` } type BetRes struct { ID int64 `json:"id" example:"1"` @@ -140,6 +156,8 @@ func ConvertCreateBet(bet Bet, createdNumber int64) CreateBetRes { Status: bet.Status, UserID: bet.UserID, CreatedNumber: createdNumber, + IsShopBet: bet.IsShopBet, + FastCode: bet.FastCode, } } diff --git a/internal/domain/branch.go b/internal/domain/branch.go index 610af17..ded005f 100644 --- a/internal/domain/branch.go +++ b/internal/domain/branch.go @@ -1,14 +1,25 @@ package domain +import ( + dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" + "github.com/jackc/pgx/v5/pgtype" +) + type Branch struct { - ID int64 - Name string - Location string - WalletID int64 - BranchManagerID int64 - CompanyID int64 - IsActive bool - IsSelfOwned bool + ID int64 + Name string + Location string + WalletID int64 + BranchManagerID int64 + CompanyID int64 + IsActive bool + IsSelfOwned bool + ProfitPercentage float32 +} + +type BranchLocation struct { + Key string `json:"key" example:"addis_ababa" ` + Name string `json:"name" example:"Addis Ababa"` } type BranchFilter struct { @@ -33,6 +44,7 @@ type BranchDetail struct { ManagerName string ManagerPhoneNumber string WalletIsActive bool + ProfitPercentage float32 } type SupportedOperation struct { @@ -48,22 +60,24 @@ type BranchOperation struct { } type CreateBranch struct { - Name string - Location string - WalletID int64 - BranchManagerID int64 - CompanyID int64 - IsSelfOwned bool + Name string + Location string + WalletID int64 + BranchManagerID int64 + CompanyID int64 + IsSelfOwned bool + ProfitPercentage float32 } type UpdateBranch struct { - ID int64 - Name *string - Location *string - BranchManagerID *int64 - CompanyID *int64 - IsSelfOwned *bool - IsActive *bool + ID int64 + Name *string + Location *string + BranchManagerID *int64 + CompanyID *int64 + IsSelfOwned *bool + IsActive *bool + ProfitPercentage *float32 } type CreateSupportedOperation struct { @@ -76,21 +90,23 @@ type CreateBranchOperation struct { } type CreateBranchReq struct { - Name string `json:"name" validate:"required,min=3,max=100" example:"4-kilo Branch"` - Location string `json:"location" validate:"required,min=3,max=100" example:"Addis Ababa"` - BranchManagerID int64 `json:"branch_manager_id" validate:"required,gt=0" example:"1"` - CompanyID *int64 `json:"company_id,omitempty" example:"1"` - IsSelfOwned *bool `json:"is_self_owned,omitempty" example:"false"` - Operations []int64 `json:"operations" validate:"required,dive,gt=0"` + Name string `json:"name" validate:"required,min=3,max=100" example:"4-kilo Branch"` + Location string `json:"location" validate:"required,min=3,max=100" example:"Addis Ababa"` + BranchManagerID int64 `json:"branch_manager_id" validate:"required,gt=0" example:"1"` + ProfitPercentage float32 `json:"profit_percentage" example:"0.1" validate:"lt=1" ` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` + IsSelfOwned *bool `json:"is_self_owned,omitempty" example:"false"` + Operations []int64 `json:"operations" validate:"required,dive,gt=0"` } type UpdateBranchReq struct { - Name *string `json:"name,omitempty" example:"4-kilo Branch"` - Location *string `json:"location,omitempty" example:"Addis Ababa"` - BranchManagerID *int64 `json:"branch_manager_id,omitempty" example:"1"` - CompanyID *int64 `json:"company_id,omitempty" example:"1"` - IsSelfOwned *bool `json:"is_self_owned,omitempty" example:"false"` - IsActive *bool `json:"is_active,omitempty" example:"false"` + Name *string `json:"name,omitempty" example:"4-kilo Branch"` + Location *string `json:"location,omitempty" example:"Addis Ababa"` + BranchManagerID *int64 `json:"branch_manager_id,omitempty" example:"1"` + CompanyID *int64 `json:"company_id,omitempty" example:"1"` + IsSelfOwned *bool `json:"is_self_owned,omitempty" example:"false"` + IsActive *bool `json:"is_active,omitempty" example:"false"` + ProfitPercentage *float32 `json:"profit_percentage,omitempty" example:"0.1" validate:"lt=1" ` } type CreateSupportedOperationReq struct { @@ -115,14 +131,15 @@ type BranchOperationRes struct { } type BranchRes struct { - ID int64 `json:"id" example:"1"` - Name string `json:"name" example:"4-kilo Branch"` - Location string `json:"location" example:"Addis Ababa"` - WalletID int64 `json:"wallet_id" example:"1"` - BranchManagerID int64 `json:"branch_manager_id" example:"1"` - CompanyID int64 `json:"company_id" example:"1"` - IsSelfOwned bool `json:"is_self_owned" example:"false"` - IsActive bool `json:"is_active" example:"false"` + ID int64 `json:"id" example:"1"` + Name string `json:"name" example:"4-kilo Branch"` + Location string `json:"location" example:"Addis Ababa"` + WalletID int64 `json:"wallet_id" example:"1"` + BranchManagerID int64 `json:"branch_manager_id" example:"1"` + CompanyID int64 `json:"company_id" example:"1"` + IsSelfOwned bool `json:"is_self_owned" example:"false"` + IsActive bool `json:"is_active" example:"false"` + ProfitPercentage float32 `json:"profit_percentage" example:"0.1"` } type BranchDetailRes struct { @@ -138,18 +155,20 @@ type BranchDetailRes struct { Balance float32 `json:"balance" example:"100.5"` IsActive bool `json:"is_active" example:"false"` WalletIsActive bool `json:"is_wallet_active" example:"false"` + ProfitPercentage float32 `json:"profit_percentage" example:"0.1"` } func ConvertBranch(branch Branch) BranchRes { return BranchRes{ - ID: branch.ID, - Name: branch.Name, - Location: branch.Location, - WalletID: branch.WalletID, - BranchManagerID: branch.BranchManagerID, - CompanyID: branch.CompanyID, - IsSelfOwned: branch.IsSelfOwned, - IsActive: branch.IsActive, + ID: branch.ID, + Name: branch.Name, + Location: branch.Location, + WalletID: branch.WalletID, + BranchManagerID: branch.BranchManagerID, + CompanyID: branch.CompanyID, + IsSelfOwned: branch.IsSelfOwned, + IsActive: branch.IsActive, + ProfitPercentage: branch.ProfitPercentage, } } @@ -167,5 +186,103 @@ func ConvertBranchDetail(branch BranchDetail) BranchDetailRes { Balance: branch.Balance.Float32(), IsActive: branch.IsActive, WalletIsActive: branch.WalletIsActive, + ProfitPercentage: branch.ProfitPercentage, } } + +func ConvertCreateBranch(branch CreateBranch) dbgen.CreateBranchParams { + return dbgen.CreateBranchParams{ + Name: branch.Name, + Location: branch.Location, + WalletID: branch.WalletID, + BranchManagerID: branch.BranchManagerID, + CompanyID: branch.CompanyID, + IsSelfOwned: branch.IsSelfOwned, + ProfitPercent: branch.ProfitPercentage, + } +} + +func ConvertDBBranchDetail(dbBranch dbgen.BranchDetail) BranchDetail { + return BranchDetail{ + ID: dbBranch.ID, + Name: dbBranch.Name, + Location: dbBranch.Location, + WalletID: dbBranch.WalletID, + BranchManagerID: dbBranch.BranchManagerID, + CompanyID: dbBranch.CompanyID, + IsSelfOwned: dbBranch.IsSelfOwned, + ManagerName: dbBranch.ManagerName.(string), + ManagerPhoneNumber: dbBranch.ManagerPhoneNumber.String, + Balance: Currency(dbBranch.Balance.Int64), + IsActive: dbBranch.IsActive, + WalletIsActive: dbBranch.WalletIsActive.Bool, + ProfitPercentage: dbBranch.ProfitPercent, + } +} + +func ConvertDBBranch(dbBranch dbgen.Branch) Branch { + return Branch{ + ID: dbBranch.ID, + Name: dbBranch.Name, + Location: dbBranch.Location, + WalletID: dbBranch.WalletID, + BranchManagerID: dbBranch.BranchManagerID, + CompanyID: dbBranch.CompanyID, + IsSelfOwned: dbBranch.IsSelfOwned, + IsActive: dbBranch.IsActive, + ProfitPercentage: dbBranch.ProfitPercent, + } +} + +func ConvertUpdateBranch(updateBranch UpdateBranch) dbgen.UpdateBranchParams { + + var newUpdateBranch dbgen.UpdateBranchParams + + newUpdateBranch.ID = updateBranch.ID + + if updateBranch.Name != nil { + newUpdateBranch.Name = pgtype.Text{ + String: *updateBranch.Name, + Valid: true, + } + } + if updateBranch.Location != nil { + newUpdateBranch.Location = pgtype.Text{ + String: *updateBranch.Location, + Valid: true, + } + } + if updateBranch.BranchManagerID != nil { + newUpdateBranch.BranchManagerID = pgtype.Int8{ + Int64: *updateBranch.BranchManagerID, + Valid: true, + } + } + if updateBranch.CompanyID != nil { + newUpdateBranch.CompanyID = pgtype.Int8{ + Int64: *updateBranch.CompanyID, + Valid: true, + } + } + if updateBranch.IsSelfOwned != nil { + newUpdateBranch.IsSelfOwned = pgtype.Bool{ + Bool: *updateBranch.IsSelfOwned, + Valid: true, + } + } + if updateBranch.IsActive != nil { + newUpdateBranch.IsActive = pgtype.Bool{ + Bool: *updateBranch.IsActive, + Valid: true, + } + } + + if updateBranch.ProfitPercentage != nil { + newUpdateBranch.ProfitPercent = pgtype.Float4{ + Float32: *updateBranch.ProfitPercentage, + Valid: true, + } + } + + return newUpdateBranch +} diff --git a/internal/domain/company.go b/internal/domain/company.go index b21e519..ec170be 100644 --- a/internal/domain/company.go +++ b/internal/domain/company.go @@ -54,8 +54,9 @@ type UpdateCompany struct { } type CreateCompanyReq struct { - Name string `json:"name" example:"CompanyName"` - AdminID int64 `json:"admin_id" example:"1"` + Name string `json:"name" example:"CompanyName"` + AdminID int64 `json:"admin_id" example:"1"` + DeductedPercentage float32 `json:"deducted_percentage" example:"0.1" validate:"lt=1"` } type UpdateCompanyReq struct { Name *string `json:"name,omitempty" example:"CompanyName"` @@ -111,6 +112,7 @@ func ConvertGetCompany(company GetCompany) GetCompanyRes { AdminFirstName: company.AdminFirstName, AdminLastName: company.AdminLastName, AdminPhoneNumber: company.AdminPhoneNumber, + } } diff --git a/internal/domain/event.go b/internal/domain/event.go index 431d998..7e4e43f 100644 --- a/internal/domain/event.go +++ b/internal/domain/event.go @@ -101,7 +101,8 @@ type UpcomingEvent struct { StartTime time.Time `json:"start_time"` // Converted from "time" field in UNIX format Source string `json:"source"` // bet api provider (bet365, betfair) Status EventStatus `json:"status"` //Match Status for event - Flagged bool `json:"flagged"` //Whether the event is flagged or not + IsFeatured bool `json:"is_featured"` //Whether the event is featured or not + IsActive bool `json:"is_active"` //Whether the event is featured or not } type MatchResult struct { EventID string @@ -120,6 +121,7 @@ type Odds struct { } type EventFilter struct { + Query ValidString SportID ValidInt32 LeagueID ValidInt32 CountryCode ValidString @@ -128,5 +130,5 @@ type EventFilter struct { Limit ValidInt64 Offset ValidInt64 MatchStatus ValidString // e.g., "upcoming", "in_play", "ended" - Flagged ValidBool + Featured ValidBool } diff --git a/internal/domain/issue_reporting.go b/internal/domain/issue_reporting.go index 59400d3..38ffe76 100644 --- a/internal/domain/issue_reporting.go +++ b/internal/domain/issue_reporting.go @@ -12,7 +12,7 @@ var ( ISSUE_TYPE_ODDS ReportedIssueType = "odds" ISSUE_TYPE_EVENTS ReportedIssueType = "events" ISSUE_TYPE_BRANCH ReportedIssueType = "branch" - ISSUE_TYPE_USER ReportedIssueType = "branch" + ISSUE_TYPE_USER ReportedIssueType = "user" ISSUE_TYPE_LOGIN ReportedIssueType = "login" ISSUE_TYPE_REGISTER ReportedIssueType = "register" ISSUE_TYPE_RESET_PASSWORD ReportedIssueType = "reset_password" diff --git a/internal/domain/otp.go b/internal/domain/otp.go index 23c8640..fc302e0 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -26,12 +26,7 @@ const ( OtpMediumSms OtpMedium = "sms" ) -type OtpProvider string -const ( - TwilioSms OtpProvider = "twilio" - AfroMessage OtpProvider = "aformessage" -) type Otp struct { ID int64 diff --git a/internal/domain/settings.go b/internal/domain/settings.go index 94e599f..3b49c5c 100644 --- a/internal/domain/settings.go +++ b/internal/domain/settings.go @@ -17,15 +17,17 @@ type SettingRes struct { } type SettingList struct { - MaxNumberOfOutcomes int64 `json:"max_number_of_outcomes"` - BetAmountLimit Currency `json:"bet_amount_limit"` - DailyTicketPerIP int64 `json:"daily_ticket_limit"` - TotalWinningLimit Currency `json:"total_winning_limit"` - AmountForBetReferral Currency `json:"amount_for_bet_referral"` - CashbackAmountCap Currency `json:"cashback_amount_cap"` + SMSProvider SMSProvider `json:"sms_provider"` + MaxNumberOfOutcomes int64 `json:"max_number_of_outcomes"` + BetAmountLimit Currency `json:"bet_amount_limit"` + DailyTicketPerIP int64 `json:"daily_ticket_limit"` + TotalWinningLimit Currency `json:"total_winning_limit"` + AmountForBetReferral Currency `json:"amount_for_bet_referral"` + CashbackAmountCap Currency `json:"cashback_amount_cap"` } type DBSettingList struct { + SMSProvider ValidString MaxNumberOfOutcomes ValidInt64 BetAmountLimit ValidInt64 DailyTicketPerIP ValidInt64 @@ -45,8 +47,27 @@ func ConvertInt64SettingsMap(dbSettingList *DBSettingList) map[string]*ValidInt6 } } +func ConvertStringSettingsMap(dbSettingList *DBSettingList) map[string]*ValidString { + return map[string]*ValidString{ + "sms_provider": &dbSettingList.SMSProvider, + } +} + +func ConvertBoolSettingsMap(dbSettingList *DBSettingList) map[string]*ValidBool { + return map[string]*ValidBool{} +} + +func ConvertFloat32SettingsMap(dbSettingList *DBSettingList) map[string]*ValidFloat32 { + return map[string]*ValidFloat32{} +} + +func ConvertTimeSettingsMap(dbSettingList *DBSettingList) map[string]*ValidTime { + return map[string]*ValidTime{} +} + func ConvertDBSetting(dbSettingList DBSettingList) SettingList { return SettingList{ + SMSProvider: SMSProvider(dbSettingList.SMSProvider.Value), MaxNumberOfOutcomes: dbSettingList.MaxNumberOfOutcomes.Value, BetAmountLimit: Currency(dbSettingList.BetAmountLimit.Value), DailyTicketPerIP: dbSettingList.DailyTicketPerIP.Value, diff --git a/internal/domain/sms.go b/internal/domain/sms.go new file mode 100644 index 0000000..48088c4 --- /dev/null +++ b/internal/domain/sms.go @@ -0,0 +1,18 @@ +package domain + +type SMSProvider string + +const ( + TwilioSms SMSProvider = "twilio" + AfroMessage SMSProvider = "afro_message" +) + +// IsValid checks if the SMSProvider is a valid enum value +func (s SMSProvider) IsValid() bool { + switch s { + case TwilioSms, AfroMessage: + return true + default: + return false + } +} diff --git a/internal/domain/wallet.go b/internal/domain/wallet.go index 7fe8f73..6ae6a1f 100644 --- a/internal/domain/wallet.go +++ b/internal/domain/wallet.go @@ -11,6 +11,7 @@ type Wallet struct { IsTransferable bool IsActive bool UserID int64 + Type WalletType UpdatedAt time.Time CreatedAt time.Time } @@ -63,6 +64,7 @@ type CreateWallet struct { IsBettable bool IsTransferable bool UserID int64 + Type WalletType } type CreateCustomerWallet struct { diff --git a/internal/repository/bet.go b/internal/repository/bet.go index bdf83d3..b9391f1 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -76,6 +76,17 @@ func convertDBBetWithOutcomes(bet dbgen.BetWithOutcome) domain.GetBet { } } +func convertDBFlag(flag dbgen.Flag) domain.Flag { + return domain.Flag{ + ID: flag.ID, + BetID: flag.BetID.Int64, + OddID: flag.OddID.Int64, + Reason: flag.Reason.String, + FlaggedAt: flag.FlaggedAt.Time, + Resolved: flag.Resolved.Bool, + } +} + func convertDBCreateBetOutcome(betOutcome domain.CreateBetOutcome) dbgen.CreateBetOutcomeParams { return dbgen.CreateBetOutcomeParams{ BetID: betOutcome.BetID, @@ -140,6 +151,35 @@ func (s *Store) CreateBetOutcome(ctx context.Context, outcomes []domain.CreateBe return rows, nil } +func (s *Store) CreateFlag(ctx context.Context, flag domain.CreateFlagReq) (domain.Flag, error) { + createFlag := dbgen.CreateFlagParams{ + BetID: pgtype.Int8{ + Int64: flag.BetID, + Valid: flag.BetID != 0, + }, + OddID: pgtype.Int8{ + Int64: flag.OddID, + Valid: flag.OddID != 0, + }, + Reason: pgtype.Text{ + String: flag.Reason, + Valid: true, + }, + } + + f, err := s.queries.CreateFlag(ctx, createFlag) + if err != nil { + domain.MongoDBLogger.Error("failed to create flag", + zap.String("flag", f.Reason.String), + zap.Any("flag_id", f.ID), + zap.Error(err), + ) + return domain.Flag{}, err + } + + return convertDBFlag(f), nil +} + func (s *Store) GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) { bet, err := s.queries.GetBetByID(ctx, id) if err != nil { @@ -237,8 +277,8 @@ func (s *Store) GetBetsForCashback(ctx context.Context) ([]domain.GetBet, error) return res, nil } -func (s *Store) GetBetCount(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { - count, err := s.queries.GetBetCount(ctx, dbgen.GetBetCountParams{ +func (s *Store) GetBetCountByUserID(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { + count, err := s.queries.GetBetCountByUserID(ctx, dbgen.GetBetCountByUserIDParams{ UserID: UserID, OutcomesHash: outcomesHash, }) @@ -250,6 +290,24 @@ func (s *Store) GetBetCount(ctx context.Context, UserID int64, outcomesHash stri return count, nil } +func (s *Store) GetBetCountByOutcomesHash(ctx context.Context, outcomesHash string) (int64, error) { + count, err := s.queries.GetBetCountByOutcomesHash(ctx, outcomesHash) + if err != nil { + return 0, err + } + + return count, nil +} + +func (s *Store) GetBetOutcomeCountByOddID(ctx context.Context, oddID int64) (int64, error) { + count, err := s.queries.GetBetOutcomeCountByOddID(ctx, oddID) + if err != nil { + return 0, err + } + + return count, nil +} + func (s *Store) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { err := s.queries.UpdateCashOut(ctx, dbgen.UpdateCashOutParams{ ID: id, @@ -486,16 +544,16 @@ func (s *Store) GetBetSummary(ctx context.Context, filter domain.ReportFilter) ( return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to get bet summary: %w", err) } - domain.MongoDBLogger.Info("GetBetSummary executed successfully", - zap.String("query", query), - zap.Any("args", args), - zap.Float64("totalStakes", float64(totalStakes)), // convert if needed - zap.Int64("totalBets", totalBets), - zap.Int64("activeBets", activeBets), - zap.Int64("totalWins", totalWins), - zap.Int64("totalLosses", totalLosses), - zap.Float64("winBalance", float64(winBalance)), // convert if needed - ) + // domain.MongoDBLogger.Info("GetBetSummary executed successfully", + // zap.String("query", query), + // zap.Any("args", args), + // zap.Float64("totalStakes", float64(totalStakes)), // convert if needed + // zap.Int64("totalBets", totalBets), + // zap.Int64("activeBets", activeBets), + // zap.Int64("totalWins", totalWins), + // zap.Int64("totalLosses", totalLosses), + // zap.Float64("winBalance", float64(winBalance)), // convert if needed + // ) return totalStakes, totalBets, activeBets, totalWins, totalLosses, winBalance, nil } diff --git a/internal/repository/branch.go b/internal/repository/branch.go index b816e05..f7a4f7a 100644 --- a/internal/repository/branch.go +++ b/internal/repository/branch.go @@ -9,100 +9,15 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func convertCreateBranch(branch domain.CreateBranch) dbgen.CreateBranchParams { - return dbgen.CreateBranchParams{ - Name: branch.Name, - Location: branch.Location, - WalletID: branch.WalletID, - BranchManagerID: branch.BranchManagerID, - CompanyID: branch.CompanyID, - IsSelfOwned: branch.IsSelfOwned, - } -} - -func convertDBBranchDetail(dbBranch dbgen.BranchDetail) domain.BranchDetail { - return domain.BranchDetail{ - ID: dbBranch.ID, - Name: dbBranch.Name, - Location: dbBranch.Location, - WalletID: dbBranch.WalletID, - BranchManagerID: dbBranch.BranchManagerID, - CompanyID: dbBranch.CompanyID, - IsSelfOwned: dbBranch.IsSelfOwned, - ManagerName: dbBranch.ManagerName.(string), - ManagerPhoneNumber: dbBranch.ManagerPhoneNumber.String, - Balance: domain.Currency(dbBranch.Balance.Int64), - IsActive: dbBranch.IsActive, - WalletIsActive: dbBranch.WalletIsActive.Bool, - } -} - -func convertDBBranch(dbBranch dbgen.Branch) domain.Branch { - return domain.Branch{ - ID: dbBranch.ID, - Name: dbBranch.Name, - Location: dbBranch.Location, - WalletID: dbBranch.WalletID, - BranchManagerID: dbBranch.BranchManagerID, - CompanyID: dbBranch.CompanyID, - IsSelfOwned: dbBranch.IsSelfOwned, - } -} - -func convertUpdateBranch(updateBranch domain.UpdateBranch) dbgen.UpdateBranchParams { - - var newUpdateBranch dbgen.UpdateBranchParams - - newUpdateBranch.ID = updateBranch.ID - - if updateBranch.Name != nil { - newUpdateBranch.Name = pgtype.Text{ - String: *updateBranch.Name, - Valid: true, - } - } - if updateBranch.Location != nil { - newUpdateBranch.Location = pgtype.Text{ - String: *updateBranch.Location, - Valid: true, - } - } - if updateBranch.BranchManagerID != nil { - newUpdateBranch.BranchManagerID = pgtype.Int8{ - Int64: *updateBranch.BranchManagerID, - Valid: true, - } - } - if updateBranch.CompanyID != nil { - newUpdateBranch.CompanyID = pgtype.Int8{ - Int64: *updateBranch.CompanyID, - Valid: true, - } - } - if updateBranch.IsSelfOwned != nil { - newUpdateBranch.IsSelfOwned = pgtype.Bool{ - Bool: *updateBranch.IsSelfOwned, - Valid: true, - } - } - if updateBranch.IsActive != nil { - newUpdateBranch.IsActive = pgtype.Bool{ - Bool: *updateBranch.IsActive, - Valid: true, - } - } - - return newUpdateBranch -} func (s *Store) CreateBranch(ctx context.Context, branch domain.CreateBranch) (domain.Branch, error) { - dbBranch, err := s.queries.CreateBranch(ctx, convertCreateBranch(branch)) + dbBranch, err := s.queries.CreateBranch(ctx, domain.ConvertCreateBranch(branch)) if err != nil { return domain.Branch{}, err } - return convertDBBranch(dbBranch), nil + return domain.ConvertDBBranch(dbBranch), nil } func (s *Store) GetBranchByID(ctx context.Context, id int64) (domain.BranchDetail, error) { @@ -110,7 +25,7 @@ func (s *Store) GetBranchByID(ctx context.Context, id int64) (domain.BranchDetai if err != nil { return domain.BranchDetail{}, err } - return convertDBBranchDetail(dbBranch), nil + return domain.ConvertDBBranchDetail(dbBranch), nil } func (s *Store) GetBranchByManagerID(ctx context.Context, branchManagerID int64) ([]domain.BranchDetail, error) { @@ -120,7 +35,7 @@ func (s *Store) GetBranchByManagerID(ctx context.Context, branchManagerID int64) } var branches []domain.BranchDetail = make([]domain.BranchDetail, 0, len(dbBranches)) for _, dbBranch := range dbBranches { - branches = append(branches, convertDBBranchDetail(dbBranch)) + branches = append(branches, domain.ConvertDBBranchDetail(dbBranch)) } return branches, nil } @@ -131,7 +46,7 @@ func (s *Store) GetBranchByCompanyID(ctx context.Context, companyID int64) ([]do } var branches []domain.BranchDetail = make([]domain.BranchDetail, 0, len(dbBranches)) for _, dbBranch := range dbBranches { - branches = append(branches, convertDBBranchDetail(dbBranch)) + branches = append(branches, domain.ConvertDBBranchDetail(dbBranch)) } return branches, nil } @@ -164,7 +79,7 @@ func (s *Store) GetAllBranches(ctx context.Context, filter domain.BranchFilter) } var branches []domain.BranchDetail = make([]domain.BranchDetail, 0, len(dbBranches)) for _, dbBranch := range dbBranches { - branches = append(branches, convertDBBranchDetail(dbBranch)) + branches = append(branches, domain.ConvertDBBranchDetail(dbBranch)) } return branches, nil } @@ -177,18 +92,18 @@ func (s *Store) SearchBranchByName(ctx context.Context, name string) ([]domain.B var branches []domain.BranchDetail = make([]domain.BranchDetail, 0, len(dbBranches)) for _, dbBranch := range dbBranches { - branches = append(branches, convertDBBranchDetail(dbBranch)) + branches = append(branches, domain.ConvertDBBranchDetail(dbBranch)) } return branches, nil } func (s *Store) UpdateBranch(ctx context.Context, branch domain.UpdateBranch) (domain.Branch, error) { - dbBranch, err := s.queries.UpdateBranch(ctx, convertUpdateBranch(branch)) + dbBranch, err := s.queries.UpdateBranch(ctx, domain.ConvertUpdateBranch(branch)) if err != nil { return domain.Branch{}, err } - return convertDBBranch(dbBranch), nil + return domain.ConvertDBBranch(dbBranch), nil } func (s *Store) DeleteBranch(ctx context.Context, id int64) error { @@ -272,7 +187,7 @@ func (s *Store) GetBranchByCashier(ctx context.Context, userID int64) (domain.Br return domain.Branch{}, err } - return convertDBBranch(branch), err + return domain.ConvertDBBranch(branch), err } func (s *Store) DeleteBranchOperation(ctx context.Context, branchID int64, operationID int64) error { diff --git a/internal/repository/event.go b/internal/repository/event.go index 0d266b3..58951ad 100644 --- a/internal/repository/event.go +++ b/internal/repository/event.go @@ -89,7 +89,7 @@ func (s *Store) GetAllUpcomingEvents(ctx context.Context) ([]domain.UpcomingEven StartTime: e.StartTime.Time.UTC(), Source: e.Source.String, Status: domain.EventStatus(e.Status.String), - Flagged: e.Flagged, + IsFeatured: e.IsFeatured, } } return upcomingEvents, nil @@ -122,7 +122,8 @@ func (s *Store) GetExpiredUpcomingEvents(ctx context.Context, filter domain.Even StartTime: e.StartTime.Time.UTC(), Source: e.Source.String, Status: domain.EventStatus(e.Status.String), - Flagged: e.Flagged, + IsFeatured: e.IsFeatured, + IsActive: e.IsActive, } } return upcomingEvents, nil @@ -139,6 +140,10 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, filter domain.Ev Int32: int32(filter.SportID.Value), Valid: filter.SportID.Valid, }, + Query: pgtype.Text{ + String: filter.Query.Value, + Valid: filter.Query.Valid, + }, Limit: pgtype.Int4{ Int32: int32(filter.Limit.Value), Valid: filter.Limit.Valid, @@ -159,9 +164,9 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, filter domain.Ev String: filter.CountryCode.Value, Valid: filter.CountryCode.Valid, }, - Flagged: pgtype.Bool{ - Bool: filter.Flagged.Valid, - Valid: filter.Flagged.Valid, + IsFeatured: pgtype.Bool{ + Bool: filter.Featured.Valid, + Valid: filter.Featured.Valid, }, }) @@ -186,7 +191,8 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, filter domain.Ev StartTime: e.StartTime.Time.UTC(), Source: e.Source.String, Status: domain.EventStatus(e.Status.String), - Flagged: e.Flagged, + IsFeatured: e.IsFeatured, + IsActive: e.IsActive, } } totalCount, err := s.queries.GetTotalEvents(ctx, dbgen.GetTotalEventsParams{ @@ -198,6 +204,10 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, filter domain.Ev Int32: int32(filter.SportID.Value), Valid: filter.SportID.Valid, }, + Query: pgtype.Text{ + String: filter.Query.Value, + Valid: filter.Query.Valid, + }, FirstStartTime: pgtype.Timestamp{ Time: filter.FirstStartTime.Value.UTC(), Valid: filter.FirstStartTime.Valid, @@ -210,9 +220,9 @@ func (s *Store) GetPaginatedUpcomingEvents(ctx context.Context, filter domain.Ev String: filter.CountryCode.Value, Valid: filter.CountryCode.Valid, }, - Flagged: pgtype.Bool{ - Bool: filter.Flagged.Valid, - Valid: filter.Flagged.Valid, + IsFeatured: pgtype.Bool{ + Bool: filter.Featured.Valid, + Valid: filter.Featured.Valid, }, }) if err != nil { @@ -244,7 +254,7 @@ func (s *Store) GetUpcomingEventByID(ctx context.Context, ID string) (domain.Upc StartTime: event.StartTime.Time.UTC(), Source: event.Source.String, Status: domain.EventStatus(event.Status.String), - Flagged: event.Flagged, + IsFeatured: event.IsFeatured, }, nil } func (s *Store) UpdateFinalScore(ctx context.Context, eventID, fullScore string, status domain.EventStatus) error { @@ -280,10 +290,10 @@ func (s *Store) UpdateEventStatus(ctx context.Context, eventID string, status do } -func (s *Store) UpdateFlagged(ctx context.Context, eventID string, flagged bool) error { - return s.queries.UpdateFlagged(ctx, dbgen.UpdateFlaggedParams{ +func (s *Store) UpdateFeatured(ctx context.Context, eventID string, isFeatured bool) error { + return s.queries.UpdateFeatured(ctx, dbgen.UpdateFeaturedParams{ ID: eventID, - Flagged: flagged, + IsFeatured: isFeatured, }) } diff --git a/internal/repository/league.go b/internal/repository/league.go index 4cb9bb6..fa6f870 100644 --- a/internal/repository/league.go +++ b/internal/repository/league.go @@ -123,7 +123,7 @@ func (s *Store) UpdateLeague(ctx context.Context, league domain.UpdateLeague) er }, IsFeatured: pgtype.Bool{ Bool: league.IsFeatured.Value, - Valid: league.IsActive.Valid, + Valid: league.IsFeatured.Valid, }, SportID: pgtype.Int4{ Int32: league.SportID.Value, diff --git a/internal/repository/location.go b/internal/repository/location.go new file mode 100644 index 0000000..d2c958b --- /dev/null +++ b/internal/repository/location.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/jackc/pgx/v5/pgtype" +) + + +func (s *Store) GetAllBranchLocations (ctx context.Context, query domain.ValidString) ([]domain.BranchLocation, error) { + locations, err := s.queries.GetAllBranchLocations(ctx, pgtype.Text{ + String: query.Value, + Valid: query.Valid, + }) + + if err != nil { + return nil, err + } + + var result []domain.BranchLocation = make([]domain.BranchLocation, 0, len(locations)) + + for _, location := range locations { + result = append(result, domain.BranchLocation{ + Key: location.Key, + Name: location.Value, + }) + } + return result, nil +} \ No newline at end of file diff --git a/internal/repository/notification.go b/internal/repository/notification.go index d874c47..1034bfc 100644 --- a/internal/repository/notification.go +++ b/internal/repository/notification.go @@ -317,39 +317,7 @@ func (s *Store) CountUnreadNotifications(ctx context.Context, userID int64) (int return count, nil } -func (s *Store) GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) { - dbCompany, err := s.queries.GetCompanyByWalletID(ctx, walletID) - if err != nil { - return domain.Company{}, err - } - return domain.Company{ - ID: dbCompany.ID, - Name: dbCompany.Name, - AdminID: dbCompany.AdminID, - WalletID: dbCompany.WalletID, - }, nil -} - -func (s *Store) GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) { - dbBranch, err := s.queries.GetBranchByWalletID(ctx, walletID) - if err != nil { - return domain.Branch{}, err - } - - return domain.Branch{ - ID: dbBranch.ID, - Name: dbBranch.Name, - Location: dbBranch.Location, - IsActive: dbBranch.IsActive, - WalletID: dbBranch.WalletID, - BranchManagerID: dbBranch.BranchManagerID, - CompanyID: dbBranch.CompanyID, - IsSelfOwned: dbBranch.IsSelfOwned, - // Creat: dbBranch.CreatedAt.Time, - // UpdatedAt: dbBranch.UpdatedAt.Time, - }, nil -} // func (s *Store) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { // dbNotifications, err := s.queries.GetAllNotifications(ctx, dbgen.GetAllNotificationsParams{ diff --git a/internal/repository/settings.go b/internal/repository/settings.go index 477ba98..8f06372 100644 --- a/internal/repository/settings.go +++ b/internal/repository/settings.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" @@ -14,6 +15,10 @@ func GetDBSettingList(settings []dbgen.Setting) (domain.SettingList, error) { var dbSettingList domain.DBSettingList var int64SettingsMap = domain.ConvertInt64SettingsMap(&dbSettingList) + var stringSettingsMap = domain.ConvertStringSettingsMap(&dbSettingList) + var boolSettingsMap = domain.ConvertBoolSettingsMap(&dbSettingList) + var float32SettingsMap = domain.ConvertFloat32SettingsMap(&dbSettingList) + var timeSettingsMap = domain.ConvertTimeSettingsMap(&dbSettingList) for _, setting := range settings { is_setting_unknown := true @@ -31,6 +36,57 @@ func GetDBSettingList(settings []dbgen.Setting) (domain.SettingList, error) { } } + for key, dbSetting := range stringSettingsMap { + if setting.Key == key { + *dbSetting = domain.ValidString{ + Value: setting.Value, + Valid: true, + } + is_setting_unknown = false + } + } + + for key, dbSetting := range boolSettingsMap { + if setting.Key == key { + value, err := strconv.ParseBool(setting.Value) + if err != nil { + return domain.SettingList{}, err + } + *dbSetting = domain.ValidBool{ + Value: value, + Valid: true, + } + is_setting_unknown = false + } + } + + for key, dbSetting := range float32SettingsMap { + if setting.Key == key { + value, err := strconv.ParseFloat(setting.Value, 32) + if err != nil { + return domain.SettingList{}, err + } + *dbSetting = domain.ValidFloat32{ + Value: float32(value), + Valid: true, + } + is_setting_unknown = false + } + } + for key, dbSetting := range timeSettingsMap { + if setting.Key == key { + value, err := time.Parse(time.RFC3339, setting.Value) + if err != nil { + return domain.SettingList{}, err + } + *dbSetting = domain.ValidTime{ + Value: value, + Valid: true, + } + is_setting_unknown = false + } + } + if is_setting_unknown { domain.MongoDBLogger.Warn("unknown setting found on database", zap.String("setting", setting.Key)) } diff --git a/internal/repository/user.go b/internal/repository/user.go index e7259f9..a70bbdb 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -490,6 +490,27 @@ func (s *Store) CreateUserWithoutOtp(ctx context.Context, user domain.User, is_c }, nil } +func (s *Store) GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error) { + userRes, err := s.queries.GetAdminByCompanyID(ctx, companyID) + + if err != nil { + return domain.User{}, err + } + return domain.User{ + ID: userRes.ID, + FirstName: userRes.FirstName, + LastName: userRes.LastName, + Email: userRes.Email.String, + PhoneNumber: userRes.PhoneNumber.String, + Role: domain.Role(userRes.Role), + EmailVerified: userRes.EmailVerified, + PhoneVerified: userRes.PhoneVerified, + CreatedAt: userRes.CreatedAt.Time, + UpdatedAt: userRes.UpdatedAt.Time, + Suspended: userRes.Suspended, + }, nil +} + // GetCustomerCounts returns total and active customer counts func (s *Store) GetCustomerCounts(ctx context.Context, filter domain.ReportFilter) (total, active, inactive int64, err error) { query := `SELECT diff --git a/internal/repository/wallet.go b/internal/repository/wallet.go index 4a6ae45..6aa8d4d 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -17,6 +17,7 @@ func convertDBWallet(wallet dbgen.Wallet) domain.Wallet { IsTransferable: wallet.IsTransferable, IsActive: wallet.IsActive, UserID: wallet.UserID, + Type: domain.WalletType(wallet.Type), UpdatedAt: wallet.UpdatedAt.Time, CreatedAt: wallet.CreatedAt.Time, } @@ -28,6 +29,7 @@ func convertCreateWallet(wallet domain.CreateWallet) dbgen.CreateWalletParams { IsBettable: wallet.IsBettable, IsTransferable: wallet.IsTransferable, UserID: wallet.UserID, + Type: string(wallet.Type), } } @@ -183,6 +185,40 @@ func (s *Store) UpdateWalletActive(ctx context.Context, id int64, isActive bool) return err } +func (s *Store) GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) { + dbCompany, err := s.queries.GetCompanyByWalletID(ctx, walletID) + if err != nil { + return domain.Company{}, err + } + + return domain.Company{ + ID: dbCompany.ID, + Name: dbCompany.Name, + AdminID: dbCompany.AdminID, + WalletID: dbCompany.WalletID, + }, nil +} + +func (s *Store) GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) { + dbBranch, err := s.queries.GetBranchByWalletID(ctx, walletID) + if err != nil { + return domain.Branch{}, err + } + + return domain.Branch{ + ID: dbBranch.ID, + Name: dbBranch.Name, + Location: dbBranch.Location, + IsActive: dbBranch.IsActive, + WalletID: dbBranch.WalletID, + BranchManagerID: dbBranch.BranchManagerID, + CompanyID: dbBranch.CompanyID, + IsSelfOwned: dbBranch.IsSelfOwned, + // Creat: dbBranch.CreatedAt.Time, + // UpdatedAt: dbBranch.UpdatedAt.Time, + }, nil +} + // GetBalanceSummary returns wallet balance summary func (s *Store) GetBalanceSummary(ctx context.Context, filter domain.ReportFilter) (domain.BalanceSummary, error) { var summary domain.BalanceSummary @@ -275,4 +311,3 @@ func (s *Store) GetTotalWallets(ctx context.Context, filter domain.ReportFilter) return total, nil } - diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index 9a116be..e29b68e 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -10,13 +10,16 @@ import ( type BetStore interface { CreateBet(ctx context.Context, bet domain.CreateBet) (domain.Bet, error) CreateBetOutcome(ctx context.Context, outcomes []domain.CreateBetOutcome) (int64, error) + CreateFlag(ctx context.Context, flag domain.CreateFlagReq) (domain.Flag, error) GetBetByID(ctx context.Context, id int64) (domain.GetBet, error) GetAllBets(ctx context.Context, filter domain.BetFilter) ([]domain.GetBet, error) GetBetByUserID(ctx context.Context, UserID int64) ([]domain.GetBet, error) GetBetByFastCode(ctx context.Context, fastcode string) (domain.GetBet, error) GetBetOutcomeByEventID(ctx context.Context, eventID int64, is_filtered bool) ([]domain.BetOutcome, error) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, error) - GetBetCount(ctx context.Context, userID int64, outcomesHash string) (int64, error) + GetBetOutcomeCountByOddID(ctx context.Context, oddID int64) (int64, error) + GetBetCountByUserID(ctx context.Context, userID int64, outcomesHash string) (int64, error) + GetBetCountByOutcomesHash(ctx context.Context, outcomesHash string) (int64, error) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error UpdateStatus(ctx context.Context, id int64, status domain.OutcomeStatus) error UpdateBetOutcomeStatus(ctx context.Context, id int64, status domain.OutcomeStatus) (domain.BetOutcome, error) diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index a3952db..986c72c 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -22,9 +22,10 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/company" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "go.uber.org/zap" ) @@ -54,6 +55,7 @@ type Service struct { branchSvc branch.Service companySvc company.Service settingSvc settings.Service + userSvc user.Service notificationSvc *notificationservice.Service logger *slog.Logger mongoLogger *zap.Logger @@ -67,6 +69,7 @@ func NewService( branchSvc branch.Service, companySvc company.Service, settingSvc settings.Service, + userSvc user.Service, notificationSvc *notificationservice.Service, logger *slog.Logger, mongoLogger *zap.Logger, @@ -215,6 +218,9 @@ func (s *Service) GenerateBetOutcome(ctx context.Context, eventID int64, marketI func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID int64, role domain.Role, companyID domain.ValidInt64) (domain.CreateBetRes, error) { settingsList, err := s.settingSvc.GetSettingList(ctx) + if err != nil { + return domain.CreateBetRes{}, err + } if req.Amount < 1 { return domain.CreateBetRes{}, ErrInvalidAmount } @@ -269,7 +275,7 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return domain.CreateBetRes{}, err } - count, err := s.GetBetCount(ctx, userID, outcomesHash) + count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) if err != nil { s.mongoLogger.Error("failed to generate cashout ID", zap.Int64("user_id", userID), @@ -398,6 +404,79 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return domain.CreateBetRes{}, err } + for i := range outcomes { + // flag odds with large amount of users betting on them + count, err := s.betStore.GetBetOutcomeCountByOddID(ctx, outcomes[i].OddID) + if err != nil { + s.mongoLogger.Error("failed to get count of bet outcome", + zap.Int64("bet_id", bet.ID), + zap.Int64("odd_id", outcomes[i].OddID), + zap.Error(err), + ) + return domain.CreateBetRes{}, err + } + + // TODO: fetch cap from settings in db + if count > 20 { + flag := domain.CreateFlagReq{ + BetID: 0, + OddID: outcomes[i].OddID, + Reason: fmt.Sprintf("too many users targeting odd - (%d)", outcomes[i].OddID), + } + + _, err := s.betStore.CreateFlag(ctx, flag) + if err != nil { + s.mongoLogger.Error("failed to create flag for bet", + zap.Int64("bet_id", bet.ID), + zap.Error(err), + ) + } + } + } + + // flag bets that have more than three outcomes + if len(outcomes) > 3 { + flag := domain.CreateFlagReq{ + BetID: bet.ID, + OddID: 0, + Reason: fmt.Sprintf("too many outcomes - (%d)", len(outcomes)), + } + + _, err := s.betStore.CreateFlag(ctx, flag) + if err != nil { + s.mongoLogger.Error("failed to create flag for bet", + zap.Int64("bet_id", bet.ID), + zap.Error(err), + ) + } + } + + // large amount of users betting on the same bet_outcomes + total_bet_count, err := s.betStore.GetBetCountByOutcomesHash(ctx, outcomesHash) + if err != nil { + s.mongoLogger.Error("failed to get bet outcomes count", + zap.String("outcomes_hash", outcomesHash), + zap.Error(err), + ) + return domain.CreateBetRes{}, err + } + + if total_bet_count > 10 { + flag := domain.CreateFlagReq{ + BetID: bet.ID, + OddID: 0, + Reason: fmt.Sprintf("too many users bet on same outcomes - (%s)", outcomesHash), + } + _, err := s.betStore.CreateFlag(ctx, flag) + + if err != nil { + s.mongoLogger.Error("failed to get bet outcomes count", + zap.String("outcomes_hash", outcomesHash), + zap.Error(err), + ) + } + } + res := domain.ConvertCreateBet(bet, rows) return res, nil @@ -417,7 +496,7 @@ func (s *Service) DeductBetFromBranchWallet(ctx context.Context, amount float32, deductedAmount := amount * company.DeductedPercentage _, err = s.walletSvc.DeductFromWallet(ctx, - walletID, domain.ToCurrency(deductedAmount), domain.BranchWalletType, domain.ValidInt64{ + walletID, domain.ToCurrency(deductedAmount), domain.ValidInt64{ Value: userID, Valid: true, }, domain.TRANSFER_DIRECT, @@ -446,7 +525,7 @@ func (s *Service) DeductBetFromCustomerWallet(ctx context.Context, amount float3 } if amount < wallets.RegularBalance.Float32() { _, err = s.walletSvc.DeductFromWallet(ctx, wallets.RegularID, - domain.ToCurrency(amount), domain.CustomerWalletType, domain.ValidInt64{}, + domain.ToCurrency(amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", amount)) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer regular wallet", @@ -465,7 +544,7 @@ func (s *Service) DeductBetFromCustomerWallet(ctx context.Context, amount float3 } // Empty the regular balance _, err = s.walletSvc.DeductFromWallet(ctx, wallets.RegularID, - wallets.RegularBalance, domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT, + wallets.RegularBalance, domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", wallets.RegularBalance.Float32())) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer regular wallet", @@ -480,7 +559,7 @@ func (s *Service) DeductBetFromCustomerWallet(ctx context.Context, amount float3 // Empty remaining from static balance remainingAmount := wallets.RegularBalance - domain.Currency(amount) _, err = s.walletSvc.DeductFromWallet(ctx, wallets.StaticID, - remainingAmount, domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT, + remainingAmount, domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing bet", remainingAmount.Float32())) if err != nil { s.mongoLogger.Error("wallet deduction failed for customer static wallet", @@ -716,7 +795,7 @@ func (s *Service) PlaceRandomBet(ctx context.Context, userID, branchID int64, le return domain.CreateBetRes{}, err } - count, err := s.GetBetCount(ctx, userID, outcomesHash) + count, err := s.GetBetCountByUserID(ctx, userID, outcomesHash) if err != nil { s.mongoLogger.Error("failed to get bet count", zap.Int64("user_id", userID), @@ -799,8 +878,12 @@ func (s *Service) GetBetByFastCode(ctx context.Context, fastcode string) (domain return s.betStore.GetBetByFastCode(ctx, fastcode) } -func (s *Service) GetBetCount(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { - return s.betStore.GetBetCount(ctx, UserID, outcomesHash) +func (s *Service) GetBetCountByUserID(ctx context.Context, UserID int64, outcomesHash string) (int64, error) { + return s.betStore.GetBetCountByUserID(ctx, UserID, outcomesHash) +} + +func (s *Service) GetBetCountByOutcomesHash(ctx context.Context, outcomesHash string) (int64, error) { + return s.betStore.GetBetCountByOutcomesHash(ctx, outcomesHash) } func (s *Service) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { @@ -817,10 +900,19 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return err } - if bet.IsShopBet || - status == domain.OUTCOME_STATUS_ERROR || - status == domain.OUTCOME_STATUS_PENDING || - status == domain.OUTCOME_STATUS_LOSS { + switch { + case bet.IsShopBet: + return s.betStore.UpdateStatus(ctx, id, status) + case status == domain.OUTCOME_STATUS_ERROR, status == domain.OUTCOME_STATUS_PENDING: + s.SendErrorStatusNotification(ctx, status, bet.UserID, "") + s.SendAdminErrorAlertNotification(ctx, status, "") + s.mongoLogger.Error("Bet Status is error", + zap.Int64("bet_id", id), + zap.Error(err), + ) + return s.betStore.UpdateStatus(ctx, id, status) + case status == domain.OUTCOME_STATUS_LOSS: + s.SendLosingStatusNotification(ctx, status, bet.UserID, "") return s.betStore.UpdateStatus(ctx, id, status) } @@ -837,10 +929,15 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc switch status { case domain.OUTCOME_STATUS_WIN: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) + s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") case domain.OUTCOME_STATUS_HALF: amount = domain.CalculateWinnings(bet.Amount, bet.TotalOdds) / 2 - default: + s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") + case domain.OUTCOME_STATUS_VOID: amount = bet.Amount + s.SendWinningStatusNotification(ctx, status, bet.UserID, amount, "") + default: + return fmt.Errorf("invalid outcome status") } _, err = s.walletSvc.AddToWallet(ctx, customerWallet.RegularID, amount, domain.ValidInt64{}, @@ -858,6 +955,207 @@ func (s *Service) UpdateStatus(ctx context.Context, id int64, status domain.Outc return s.betStore.UpdateStatus(ctx, id, status) } +func (s *Service) SendWinningStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, winningAmount domain.Currency, extra string) error { + + var headline string + var message string + + switch status { + case domain.OUTCOME_STATUS_WIN: + headline = "You Bet Has Won!" + message = fmt.Sprintf( + "You have been awarded %.2f", + winningAmount.Float32(), + ) + case domain.OUTCOME_STATUS_HALF: + headline = "You have a half win" + message = fmt.Sprintf( + "You have been awarded %.2f", + winningAmount.Float32(), + ) + case domain.OUTCOME_STATUS_VOID: + headline = "Your bet has been refunded" + message = fmt.Sprintf( + "You have been awarded %.2f", + winningAmount.Float32(), + ) + } + + betNotification := &domain.Notification{ + RecipientID: userID, + Type: domain.NOTIFICATION_TYPE_BET_RESULT, + Level: domain.NotificationLevelSuccess, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelInApp, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 2, + Metadata: fmt.Appendf(nil, `{ + "winning_amount":%.2f, + "status":%v + "more": %v + }`, winningAmount.Float32(), status, extra), + } + + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + + betNotification.DeliveryChannel = domain.DeliveryChannelEmail + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + + return nil +} + +func (s *Service) SendLosingStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { + + var headline string + var message string + + switch status { + case domain.OUTCOME_STATUS_LOSS: + headline = "Your bet has lost" + message = "Better luck next time" + } + + betNotification := &domain.Notification{ + RecipientID: userID, + Type: domain.NOTIFICATION_TYPE_BET_RESULT, + Level: domain.NotificationLevelSuccess, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelInApp, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 2, + Metadata: fmt.Appendf(nil, `{ + "status":%v + "more": %v + }`, status, extra), + } + + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + + betNotification.DeliveryChannel = domain.DeliveryChannelEmail + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + + return nil +} + +func (s *Service) SendErrorStatusNotification(ctx context.Context, status domain.OutcomeStatus, userID int64, extra string) error { + + var headline string + var message string + + switch status { + case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: + headline = "There was an error with your bet" + message = "We have encounter an error with your bet. We will fix it as soon as we can" + } + + errorSeverityLevel := domain.NotificationErrorSeverityFatal + + betNotification := &domain.Notification{ + RecipientID: userID, + Type: domain.NOTIFICATION_TYPE_BET_RESULT, + Level: domain.NotificationLevelSuccess, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelInApp, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 1, + ErrorSeverity: &errorSeverityLevel, + Metadata: fmt.Appendf(nil, `{ + "status":%v + "more": %v + }`, status, extra), + } + + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + + betNotification.DeliveryChannel = domain.DeliveryChannelEmail + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + return err + } + return nil +} + +func (s *Service) SendAdminErrorAlertNotification(ctx context.Context, status domain.OutcomeStatus, extra string) error { + + var headline string + var message string + + switch status { + case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: + headline = "There was an error with your bet" + message = "We have encounter an error with your bet. We will fix it as soon as we can" + } + + betNotification := &domain.Notification{ + Type: domain.NOTIFICATION_TYPE_BET_RESULT, + Level: domain.NotificationLevelSuccess, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelEmail, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 2, + Metadata: fmt.Appendf(nil, `{ + "status":%v + "more": %v + }`, status, extra), + } + + users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ + Role: string(domain.RoleAdmin), + }) + + if err != nil { + s.mongoLogger.Error("failed to get admin recipients", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + for _, user := range users { + betNotification.RecipientID = user.ID + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + s.mongoLogger.Error("failed to send admin notification", + zap.Int64("admin_id", user.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + betNotification.DeliveryChannel = domain.DeliveryChannelEmail + if err := s.notificationSvc.SendNotification(ctx, betNotification); err != nil { + s.mongoLogger.Error("failed to send email admin notification", + zap.Int64("admin_id", user.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + } + + return nil +} + func (s *Service) CheckBetOutcomeForBet(ctx context.Context, betID int64) (domain.OutcomeStatus, error) { betOutcomes, err := s.betStore.GetBetOutcomeByBetID(ctx, betID) if err != nil { @@ -1033,7 +1331,7 @@ func (s *Service) ProcessBetCashback(ctx context.Context) error { ) continue } - cashbackAmount := math.Min(float64(settingsList.CashbackAmountCap), float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds))) + cashbackAmount := math.Min(float64(settingsList.CashbackAmountCap.Float32()), float64(calculateCashbackAmount(bet.Amount.Float32(), bet.TotalOdds))) _, err = s.walletSvc.AddToWallet(ctx, wallets.StaticID, domain.ToCurrency(float32(cashbackAmount)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, fmt.Sprintf("cashback amount of %f added to users static wallet", cashbackAmount)) diff --git a/internal/services/branch/branch_locations.go b/internal/services/branch/branch_locations.go new file mode 100644 index 0000000..ad25f33 --- /dev/null +++ b/internal/services/branch/branch_locations.go @@ -0,0 +1,11 @@ +package branch + +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +func (s *Service) GetAllBranchLocations(ctx context.Context, query domain.ValidString) ([]domain.BranchLocation, error) { + return s.branchStore.GetAllBranchLocations(ctx, query) +} diff --git a/internal/services/branch/port.go b/internal/services/branch/port.go index 3f242c8..8b17ae1 100644 --- a/internal/services/branch/port.go +++ b/internal/services/branch/port.go @@ -29,4 +29,6 @@ type BranchStore interface { GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, error) GetBranchesByCompany(ctx context.Context, companyID int64) ([]domain.Branch, error) + + GetAllBranchLocations(ctx context.Context, query domain.ValidString) ([]domain.BranchLocation, error) } diff --git a/internal/services/branch/service.go b/internal/services/branch/service.go index eccb764..9e4f641 100644 --- a/internal/services/branch/service.go +++ b/internal/services/branch/service.go @@ -78,3 +78,4 @@ func (s *Service) GetAllCompaniesBranch(ctx context.Context) ([]domain.Company, func (s *Service) GetBranchesByCompany(ctx context.Context, companyID int64) ([]domain.Branch, error) { return s.branchStore.GetBranchesByCompany(ctx, companyID) } + diff --git a/internal/services/event/port.go b/internal/services/event/port.go index fafb8e8..c95b516 100644 --- a/internal/services/event/port.go +++ b/internal/services/event/port.go @@ -16,5 +16,5 @@ type Service interface { // GetAndStoreMatchResult(ctx context.Context, eventID string) error UpdateFinalScore(ctx context.Context, eventID, fullScore string, status domain.EventStatus) error UpdateEventStatus(ctx context.Context, eventID string, status domain.EventStatus) error - UpdateFlagged(ctx context.Context, eventID string, flagged bool) error + UpdateFeatured(ctx context.Context, eventID string, flagged bool) error } diff --git a/internal/services/event/service.go b/internal/services/event/service.go index 7315b45..23ccc9b 100644 --- a/internal/services/event/service.go +++ b/internal/services/event/service.go @@ -369,8 +369,8 @@ func (s *service) UpdateEventStatus(ctx context.Context, eventID string, status return s.store.UpdateEventStatus(ctx, eventID, status) } -func (s *service) UpdateFlagged(ctx context.Context, eventID string, flagged bool) error { - return s.store.UpdateFlagged(ctx, eventID, flagged) +func (s *service) UpdateFeatured(ctx context.Context, eventID string, flagged bool) error { + return s.store.UpdateFeatured(ctx, eventID, flagged) } // func (s *service) GetAndStoreMatchResult(ctx context.Context, eventID string) error { diff --git a/internal/services/messenger/email.go b/internal/services/messenger/email.go new file mode 100644 index 0000000..ddb3542 --- /dev/null +++ b/internal/services/messenger/email.go @@ -0,0 +1,26 @@ +package messenger + +import ( + "context" + "github.com/resend/resend-go/v2" + +) + +func (s *Service) SendEmail(ctx context.Context, receiverEmail, message string, subject string) error { + apiKey := s.config.ResendApiKey + client := resend.NewClient(apiKey) + formattedSenderEmail := "FortuneBets <" + s.config.ResendSenderEmail + ">" + params := &resend.SendEmailRequest{ + From: formattedSenderEmail, + To: []string{receiverEmail}, + Subject: subject, + Text: message, + } + + _, err := client.Emails.Send(params) + if err != nil { + return err + } + + return nil +} diff --git a/internal/services/messenger/service.go b/internal/services/messenger/service.go new file mode 100644 index 0000000..27a48af --- /dev/null +++ b/internal/services/messenger/service.go @@ -0,0 +1,21 @@ +package messenger + +import ( + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" +) + +type Service struct { + settingSvc *settings.Service + config *config.Config +} + +func NewService( + settingSvc *settings.Service, + cfg *config.Config, +) *Service { + return &Service{ + settingSvc: settingSvc, + config: cfg, + } +} diff --git a/internal/services/messenger/sms.go b/internal/services/messenger/sms.go new file mode 100644 index 0000000..d750d9a --- /dev/null +++ b/internal/services/messenger/sms.go @@ -0,0 +1,85 @@ +package messenger + +import ( + "context" + "errors" + "fmt" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + afro "github.com/amanuelabay/afrosms-go" + "github.com/twilio/twilio-go" + twilioApi "github.com/twilio/twilio-go/rest/api/v2010" +) + +var ( + ErrSMSProviderNotFound = errors.New("SMS Provider Not Found") +) + +func (s *Service) SendSMS(ctx context.Context, receiverPhone, message string) error { + + settingsList, err := s.settingSvc.GetSettingList(ctx) + + if err != nil { + return err + } + + switch settingsList.SMSProvider { + case domain.AfroMessage: + return s.SendAfroMessageSMS(ctx, receiverPhone, message) + case domain.TwilioSms: + return s.SendTwilioSMS(ctx, receiverPhone, message) + default: + return ErrSMSProviderNotFound + } +} + +func (s *Service) SendAfroMessageSMS(ctx context.Context, receiverPhone, message string) error { + apiKey := s.config.AFRO_SMS_API_KEY + senderName := s.config.AFRO_SMS_SENDER_NAME + hostURL := s.config.ADRO_SMS_HOST_URL + endpoint := "/api/send" + + // API endpoint has been updated + // TODO: no need for package for the afro message operations (pretty simple stuff) + request := afro.GetRequest(apiKey, endpoint, hostURL) + request.BaseURL = "https://api.afromessage.com/api/send" + + request.Method = "GET" + request.Sender(senderName) + request.To(receiverPhone, message) + + response, err := afro.MakeRequestWithContext(ctx, request) + if err != nil { + return err + } + + if response["acknowledge"] == "success" { + return nil + } else { + fmt.Println(response["response"].(map[string]interface{})) + return errors.New("SMS delivery failed") + } +} + +func (s *Service) SendTwilioSMS(ctx context.Context, receiverPhone, message string) error { + accountSid := s.config.TwilioAccountSid + authToken := s.config.TwilioAuthToken + senderPhone := s.config.TwilioSenderPhoneNumber + + client := twilio.NewRestClientWithParams(twilio.ClientParams{ + Username: accountSid, + Password: authToken, + }) + + params := &twilioApi.CreateMessageParams{} + params.SetTo(receiverPhone) + params.SetFrom(senderPhone) + params.SetBody(message) + + _, err := client.Api.CreateMessage(params) + if err != nil { + return fmt.Errorf("%s", "Error sending SMS message: %s"+err.Error()) + } + + return nil +} diff --git a/internal/services/notfication/port.go b/internal/services/notification/port.go similarity index 87% rename from internal/services/notfication/port.go rename to internal/services/notification/port.go index d20f4bc..2d03f80 100644 --- a/internal/services/notfication/port.go +++ b/internal/services/notification/port.go @@ -8,8 +8,6 @@ import ( ) type NotificationStore interface { - GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) - GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) SendNotification(ctx context.Context, notification *domain.Notification) error MarkAsRead(ctx context.Context, notificationID string, recipientID int64) error ListNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, error) diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go new file mode 100644 index 0000000..01fbe15 --- /dev/null +++ b/internal/services/notification/service.go @@ -0,0 +1,646 @@ +package notificationservice + +import ( + "context" + "encoding/json" + "fmt" + + // "errors" + "log/slog" + "sync" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "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/services/messenger" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "go.uber.org/zap" + + // "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/ws" + // afro "github.com/amanuelabay/afrosms-go" + "github.com/gorilla/websocket" + "github.com/redis/go-redis/v9" +) + +type Service struct { + repo repository.NotificationRepository + Hub *ws.NotificationHub + notificationStore NotificationStore + connections sync.Map + notificationCh chan *domain.Notification + stopCh chan struct{} + config *config.Config + userSvc *user.Service + messengerSvc *messenger.Service + mongoLogger *zap.Logger + logger *slog.Logger + redisClient *redis.Client +} + +func New(repo repository.NotificationRepository, + mongoLogger *zap.Logger, + logger *slog.Logger, + cfg *config.Config, + messengerSvc *messenger.Service, + userSvc *user.Service, +) *Service { + hub := ws.NewNotificationHub() + rdb := redis.NewClient(&redis.Options{ + Addr: cfg.RedisAddr, // e.g., "redis:6379" + }) + + svc := &Service{ + repo: repo, + Hub: hub, + mongoLogger: mongoLogger, + logger: logger, + connections: sync.Map{}, + notificationCh: make(chan *domain.Notification, 1000), + stopCh: make(chan struct{}), + messengerSvc: messengerSvc, + userSvc: userSvc, + config: cfg, + redisClient: rdb, + } + + go hub.Run() + go svc.startWorker() + go svc.startRetryWorker() + go svc.RunRedisSubscriber(context.Background()) + + return svc +} + +func (s *Service) addConnection(recipientID int64, c *websocket.Conn) error { + if c == nil { + s.mongoLogger.Warn("[NotificationSvc.AddConnection] Attempted to add nil WebSocket connection", + zap.Int64("recipientID", recipientID), + zap.Time("timestamp", time.Now()), + ) + return fmt.Errorf("Invalid Websocket Connection") + } + + s.connections.Store(recipientID, c) + s.mongoLogger.Info("[NotificationSvc.AddConnection] Added WebSocket connection", + zap.Int64("recipientID", recipientID), + zap.Time("timestamp", time.Now()), + ) + + return nil +} + +func (s *Service) SendNotification(ctx context.Context, notification *domain.Notification) error { + notification.ID = helpers.GenerateID() + notification.Timestamp = time.Now() + notification.DeliveryStatus = domain.DeliveryStatusPending + + created, err := s.repo.CreateNotification(ctx, notification) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.SendNotification] Failed to create notification", + zap.String("id", notification.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + 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.mongoLogger.Warn("[NotificationSvc.SendNotification] Notification channel full, dropping notification", + zap.String("id", notification.ID), + zap.Time("timestamp", time.Now()), + ) + } + + return nil +} + +func (s *Service) MarkAsRead(ctx context.Context, notificationIDs []string, recipientID int64) error { + for _, notificationID := range notificationIDs { + _, err := s.repo.UpdateNotificationStatus(ctx, notificationID, string(domain.DeliveryStatusSent), true, nil) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", + zap.String("notificationID", notificationID), + zap.Int64("recipientID", recipientID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + 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.mongoLogger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", + zap.String("notificationID", notificationID), + zap.Int64("recipientID", recipientID), + zap.Time("timestamp", time.Now()), + ) + } + + return nil +} + +func (s *Service) ListNotifications(ctx context.Context, recipientID int64, limit, offset int) ([]domain.Notification, error) { + notifications, err := s.repo.ListNotifications(ctx, recipientID, limit, offset) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.ListNotifications] Failed to list notifications", + zap.Int64("recipientID", recipientID), + zap.Int("limit", limit), + zap.Int("offset", offset), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return nil, err + } + s.mongoLogger.Info("[NotificationSvc.ListNotifications] Successfully listed notifications", + zap.Int64("recipientID", recipientID), + zap.Int("count", len(notifications)), + zap.Time("timestamp", time.Now()), + ) + return notifications, nil +} + +func (s *Service) GetAllNotifications(ctx context.Context, limit, offset int) ([]domain.Notification, error) { + notifications, err := s.repo.GetAllNotifications(ctx, limit, offset) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.ListNotifications] Failed to get all notifications", + zap.Int("limit", limit), + zap.Int("offset", offset), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return nil, err + } + s.mongoLogger.Info("[NotificationSvc.ListNotifications] Successfully retrieved all notifications", + zap.Int("count", len(notifications)), + zap.Time("timestamp", time.Now()), + ) + return notifications, nil +} + +func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { + err := s.addConnection(recipientID, c) + + if err != nil { + s.mongoLogger.Error("[NotificationSvc.ConnectWebSocket] Failed to create WebSocket connection", + zap.Int64("recipientID", recipientID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + s.mongoLogger.Info("[NotificationSvc.ConnectWebSocket] WebSocket connection established", + zap.Int64("recipientID", recipientID), + zap.Time("timestamp", time.Now()), + ) + return nil +} + +func (s *Service) DisconnectWebSocket(recipientID int64) { + if conn, loaded := s.connections.LoadAndDelete(recipientID); loaded { + conn.(*websocket.Conn).Close() + // s.logger.Info("[NotificationSvc.DisconnectWebSocket] Disconnected WebSocket", "recipientID", recipientID) + s.mongoLogger.Info("[NotificationSvc.DisconnectWebSocket] Disconnected WebSocket", + zap.Int64("recipientID", recipientID), + zap.Time("timestamp", time.Now()), + ) + } +} + +// func (s *Service) SendSMS(ctx context.Context, recipientID int64, message string) error { +// s.logger.Info("[NotificationSvc.SendSMS] SMS notification requested", "recipientID", recipientID, "message", message) + +// apiKey := s.config.AFRO_SMS_API_KEY +// senderName := s.config.AFRO_SMS_SENDER_NAME +// receiverPhone := s.config.AFRO_SMS_RECEIVER_PHONE_NUMBER +// hostURL := s.config.ADRO_SMS_HOST_URL +// endpoint := "/api/send" + +// request := afro.GetRequest(apiKey, endpoint, hostURL) +// request.Method = "GET" +// request.Sender(senderName) +// request.To(receiverPhone, message) + +// response, err := afro.MakeRequestWithContext(ctx, request) +// if err != nil { +// s.logger.Error("[NotificationSvc.SendSMS] Failed to send SMS", "recipientID", recipientID, "error", err) +// return err +// } + +// if response["acknowledge"] == "success" { +// s.logger.Info("[NotificationSvc.SendSMS] SMS sent successfully", "recipientID", recipientID) +// } else { +// s.logger.Error("[NotificationSvc.SendSMS] Failed to send SMS", "recipientID", recipientID, "response", response["response"]) +// return errors.New("SMS delivery failed: " + response["response"].(string)) +// } + +// return nil +// } + +// func (s *Service) SendEmail(ctx context.Context, recipientID int64, subject, message string) error { +// s.logger.Info("[NotificationSvc.SendEmail] Email notification requested", "recipientID", recipientID, "subject", subject) +// return nil +// } + +func (s *Service) startWorker() { + for { + select { + case notification := <-s.notificationCh: + s.handleNotification(notification) + case <-s.stopCh: + s.mongoLogger.Info("[NotificationSvc.StartWorker] Worker stopped", + zap.Time("timestamp", time.Now()), + ) + return + } + } +} + +func (s *Service) ListRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) { + return s.repo.ListRecipientIDs(ctx, receiver) +} + +func (s *Service) handleNotification(notification *domain.Notification) { + ctx := context.Background() + + switch notification.DeliveryChannel { + case domain.DeliveryChannelSMS: + err := s.SendNotificationSMS(ctx, notification.RecipientID, notification.Payload.Message) + if err != nil { + notification.DeliveryStatus = domain.DeliveryStatusFailed + } else { + notification.DeliveryStatus = domain.DeliveryStatusSent + } + + case domain.DeliveryChannelEmail: + err := s.SendNotificationEmail(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.mongoLogger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", + zap.String("channel", string(notification.DeliveryChannel)), + zap.Time("timestamp", time.Now()), + ) + notification.DeliveryStatus = domain.DeliveryStatusFailed + } + } + + if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { + s.mongoLogger.Error("[NotificationSvc.HandleNotification] Failed to update notification status", + zap.String("id", notification.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } +} + +func (s *Service) SendNotificationSMS(ctx context.Context, recipientID int64, message string) error { + // Get User Phone Number + user, err := s.userSvc.GetUserByID(ctx, recipientID) + + if err != nil { + return err + } + + if !user.PhoneVerified { + return fmt.Errorf("Cannot send notification to unverified phone number") + } + + if user.PhoneNumber == "" { + return fmt.Errorf("Phone Number is invalid") + } + err = s.messengerSvc.SendSMS(ctx, user.PhoneNumber, message) + if err != nil { + return err + } + + return nil +} + +func (s *Service) SendNotificationEmail(ctx context.Context, recipientID int64, message string, subject string) error { + // Get User Phone Number + user, err := s.userSvc.GetUserByID(ctx, recipientID) + + if err != nil { + return err + } + + if !user.EmailVerified { + return fmt.Errorf("Cannot send notification to unverified email") + } + + if user.PhoneNumber == "" { + return fmt.Errorf("Email is invalid") + } + err = s.messengerSvc.SendEmail(ctx, user.PhoneNumber, message, subject) + if err != nil { + return err + } + + return nil +} + +func (s *Service) startRetryWorker() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.retryFailedNotifications() + case <-s.stopCh: + s.mongoLogger.Info("[NotificationSvc.StartRetryWorker] Retry worker stopped", + zap.Time("timestamp", time.Now()), + ) + return + } + } +} + +func (s *Service) retryFailedNotifications() { + ctx := context.Background() + failedNotifications, err := s.repo.ListFailedNotifications(ctx, 100) + if err != nil { + s.mongoLogger.Error("[NotificationSvc.RetryFailedNotifications] Failed to list failed notifications", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return + } + + for _, n := range failedNotifications { + notification := &n + go func(notification *domain.Notification) { + for attempt := 0; attempt < 3; attempt++ { + time.Sleep(time.Duration(attempt) * time.Second) + switch notification.DeliveryChannel { + case domain.DeliveryChannelSMS: + if err := s.SendNotificationSMS(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.mongoLogger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", + zap.String("id", notification.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } else { + s.mongoLogger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", + zap.String("id", notification.ID), + zap.Time("timestamp", time.Now()), + ) + } + + return + } + case domain.DeliveryChannelEmail: + if err := s.SendNotificationEmail(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.mongoLogger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", + zap.String("id", notification.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } else { + s.mongoLogger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", + zap.String("id", notification.ID), + zap.Time("timestamp", time.Now()), + ) + } + + return + } + } + } + s.mongoLogger.Error("[NotificationSvc.RetryFailedNotifications] Max retries reached for notification", + zap.String("id", notification.ID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + }(notification) + } +} + +func (s *Service) CountUnreadNotifications(ctx context.Context, recipient_id int64) (int64, error) { + return s.repo.CountUnreadNotifications(ctx, recipient_id) +} + +// func (s *Service) GetNotificationCounts(ctx context.Context, filter domain.ReportFilter) (total, read, unread int64, err error){ +// return s.repo.Get(ctx, filter) +// } + +func (s *Service) RunRedisSubscriber(ctx context.Context) { + pubsub := s.redisClient.Subscribe(ctx, "live_metrics") + defer pubsub.Close() + + ch := pubsub.Channel() + for msg := range ch { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(msg.Payload), &parsed); err != nil { + // s.logger.Error("invalid Redis message format", "payload", msg.Payload, "error", err) + s.mongoLogger.Error("invalid Redis message format", + zap.String("payload", msg.Payload), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + continue + } + + eventType, _ := parsed["type"].(string) + payload := parsed["payload"] + recipientID, hasRecipient := parsed["recipient_id"] + recipientType, _ := parsed["recipient_type"].(string) + + message := map[string]interface{}{ + "type": eventType, + "payload": payload, + } + + if hasRecipient { + message["recipient_id"] = recipientID + message["recipient_type"] = recipientType + } + + s.Hub.Broadcast <- message + } +} + +func (s *Service) UpdateLiveWalletMetrics(ctx context.Context, companies []domain.GetCompany, branches []domain.BranchWallet) error { + const key = "live_metrics" + + companyBalances := make([]domain.CompanyWalletBalance, 0, len(companies)) + for _, c := range companies { + companyBalances = append(companyBalances, domain.CompanyWalletBalance{ + CompanyID: c.ID, + CompanyName: c.Name, + Balance: float64(c.WalletBalance.Float32()), + }) + } + + branchBalances := make([]domain.BranchWalletBalance, 0, len(branches)) + for _, b := range branches { + branchBalances = append(branchBalances, domain.BranchWalletBalance{ + BranchID: b.ID, + BranchName: b.Name, + CompanyID: b.CompanyID, + Balance: float64(b.Balance.Float32()), + }) + } + + payload := domain.LiveWalletMetrics{ + Timestamp: time.Now(), + CompanyBalances: companyBalances, + BranchBalances: branchBalances, + } + + updatedData, err := json.Marshal(payload) + if err != nil { + return err + } + + if err := s.redisClient.Set(ctx, key, updatedData, 0).Err(); err != nil { + return err + } + + if err := s.redisClient.Publish(ctx, key, updatedData).Err(); err != nil { + return err + } + return nil +} + +func (s *Service) GetLiveMetrics(ctx context.Context) (domain.LiveMetric, error) { + const key = "live_metrics" + var metric domain.LiveMetric + + val, err := s.redisClient.Get(ctx, key).Result() + if err == redis.Nil { + // Key does not exist yet, return zero-valued struct + return domain.LiveMetric{}, nil + } else if err != nil { + return domain.LiveMetric{}, err + } + + if err := json.Unmarshal([]byte(val), &metric); err != nil { + return domain.LiveMetric{}, err + } + + return metric, nil +} + +// func (s *Service) UpdateLiveWalletMetricForWallet(ctx context.Context, wallet domain.Wallet) { +// var ( +// payload domain.LiveWalletMetrics +// event map[string]interface{} +// key = "live_metrics" +// ) + +// // Try company first +// company, companyErr := s.notificationStore.GetCompanyByWalletID(ctx, wallet.ID) +// if companyErr == nil { +// payload = domain.LiveWalletMetrics{ +// Timestamp: time.Now(), +// CompanyBalances: []domain.CompanyWalletBalance{{ +// CompanyID: company.ID, +// CompanyName: company.Name, +// Balance: float64(wallet.Balance), +// }}, +// BranchBalances: []domain.BranchWalletBalance{}, +// } + +// event = map[string]interface{}{ +// "type": "LIVE_WALLET_METRICS_UPDATE", +// "recipient_id": company.ID, +// "recipient_type": "company", +// "payload": payload, +// } +// } else { +// // Try branch next +// branch, branchErr := s.notificationStore.GetBranchByWalletID(ctx, wallet.ID) +// if branchErr == nil { +// payload = domain.LiveWalletMetrics{ +// Timestamp: time.Now(), +// CompanyBalances: []domain.CompanyWalletBalance{}, +// BranchBalances: []domain.BranchWalletBalance{{ +// BranchID: branch.ID, +// BranchName: branch.Name, +// CompanyID: branch.CompanyID, +// Balance: float64(wallet.Balance), +// }}, +// } + +// event = map[string]interface{}{ +// "type": "LIVE_WALLET_METRICS_UPDATE", +// "recipient_id": branch.ID, +// "recipient_type": "branch", +// "payload": payload, +// } +// } else { +// // Neither company nor branch matched this wallet +// // s.logger.Warn("wallet not linked to any company or branch", "walletID", wallet.ID) +// s.mongoLogger.Warn("wallet not linked to any company or branch", +// zap.Int64("walletID", wallet.ID), +// zap.Time("timestamp", time.Now()), +// ) +// return +// } +// } + +// // Save latest metric to Redis +// if jsonBytes, err := json.Marshal(payload); err == nil { +// s.redisClient.Set(ctx, key, jsonBytes, 0) +// } else { +// // s.logger.Error("failed to marshal wallet metrics payload", "walletID", wallet.ID, "err", err) +// s.mongoLogger.Error("failed to marshal wallet metrics payload", +// zap.Int64("walletID", wallet.ID), +// zap.Error(err), +// zap.Time("timestamp", time.Now()), +// ) +// } + +// // Publish via Redis +// if jsonEvent, err := json.Marshal(event); err == nil { +// s.redisClient.Publish(ctx, key, jsonEvent) +// } else { +// // s.logger.Error("failed to marshal event payload", "walletID", wallet.ID, "err", err) +// s.mongoLogger.Error("failed to marshal event payload", +// zap.Int64("walletID", wallet.ID), +// zap.Error(err), +// zap.Time("timestamp", time.Now()), +// ) +// } + +// // Broadcast over WebSocket +// s.Hub.Broadcast <- event +// } + + diff --git a/internal/services/result/service.go b/internal/services/result/service.go index a19ede7..faa4e10 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -16,7 +16,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/bet" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" ) diff --git a/internal/services/ticket/service.go b/internal/services/ticket/service.go index 13929ed..c67a657 100644 --- a/internal/services/ticket/service.go +++ b/internal/services/ticket/service.go @@ -9,7 +9,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "go.uber.org/zap" diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 0094210..c14403c 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -2,40 +2,36 @@ package user import ( "context" - "errors" "fmt" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" - afro "github.com/amanuelabay/afrosms-go" - "github.com/resend/resend-go/v2" - "github.com/twilio/twilio-go" - twilioApi "github.com/twilio/twilio-go/rest/api/v2010" "golang.org/x/crypto/bcrypt" ) -func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.OtpProvider) error { +func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpFor, medium domain.OtpMedium, provider domain.SMSProvider) error { otpCode := helpers.GenerateOTP() message := fmt.Sprintf("Welcome to Fortune bets, your OTP is %s please don't share with anyone.", otpCode) switch medium { case domain.OtpMediumSms: + switch provider { - case "twilio": - if err := s.SendTwilioSMSOTP(ctx, sentTo, message, provider); err != nil { + case domain.TwilioSms: + if err := s.messengerSvc.SendTwilioSMS(ctx, sentTo, message); err != nil { return err } - case "afromessage": - if err := s.SendAfroMessageSMSOTP(ctx, sentTo, message, provider); err != nil { + case domain.AfroMessage: + if err := s.messengerSvc.SendAfroMessageSMS(ctx, sentTo, message); err != nil { return err } default: return fmt.Errorf("invalid sms provider: %s", provider) } case domain.OtpMediumEmail: - if err := s.SendEmailOTP(ctx, sentTo, message); err != nil { + if err := s.messengerSvc.SendEmail(ctx, sentTo, message, "FortuneBets - One Time Password"); err != nil { return err } } @@ -61,73 +57,3 @@ func hashPassword(plaintextPassword string) ([]byte, error) { return hash, nil } - -func (s *Service) SendAfroMessageSMSOTP(ctx context.Context, receiverPhone, message string, provider domain.OtpProvider) error { - apiKey := s.config.AFRO_SMS_API_KEY - senderName := s.config.AFRO_SMS_SENDER_NAME - hostURL := s.config.ADRO_SMS_HOST_URL - endpoint := "/api/send" - - // API endpoint has been updated - // TODO: no need for package for the afro message operations (pretty simple stuff) - request := afro.GetRequest(apiKey, endpoint, hostURL) - request.BaseURL = "https://api.afromessage.com/api/send" - - request.Method = "GET" - request.Sender(senderName) - request.To(receiverPhone, message) - - response, err := afro.MakeRequestWithContext(ctx, request) - if err != nil { - return err - } - - if response["acknowledge"] == "success" { - return nil - } else { - fmt.Println(response["response"].(map[string]interface{})) - return errors.New("SMS delivery failed") - } -} - -func (s *Service) SendTwilioSMSOTP(ctx context.Context, receiverPhone, message string, provider domain.OtpProvider) error { - accountSid := s.config.TwilioAccountSid - authToken := s.config.TwilioAuthToken - senderPhone := s.config.TwilioSenderPhoneNumber - - client := twilio.NewRestClientWithParams(twilio.ClientParams{ - Username: accountSid, - Password: authToken, - }) - - params := &twilioApi.CreateMessageParams{} - params.SetTo(receiverPhone) - params.SetFrom(senderPhone) - params.SetBody(message) - - _, err := client.Api.CreateMessage(params) - if err != nil { - return fmt.Errorf("%s", "Error sending SMS message: %s" + err.Error()) - } - - return nil -} - -func (s *Service) SendEmailOTP(ctx context.Context, receiverEmail, message string) error { - apiKey := s.config.ResendApiKey - client := resend.NewClient(apiKey) - formattedSenderEmail := "FortuneBets <" + s.config.ResendSenderEmail + ">" - params := &resend.SendEmailRequest{ - From: formattedSenderEmail, - To: []string{receiverEmail}, - Subject: "FortuneBets - One Time Password", - Text: message, - } - - _, err := client.Emails.Send(params) - if err != nil { - return err - } - - return nil -} diff --git a/internal/services/user/direct.go b/internal/services/user/direct.go index 9ad4bf4..bbad0b6 100644 --- a/internal/services/user/direct.go +++ b/internal/services/user/direct.go @@ -43,8 +43,6 @@ func (s *Service) DeleteUser(ctx context.Context, id int64) error { return s.userStore.DeleteUser(ctx, id) } - - func (s *Service) GetAllUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { // Get all Users return s.userStore.GetAllUsers(ctx, filter) @@ -58,7 +56,10 @@ func (s *Service) GetCashiersByBranch(ctx context.Context, branchID int64) ([]do return s.userStore.GetCashiersByBranch(ctx, branchID) } -func (s *Service) GetAllCashiers(ctx context.Context, filter domain.UserFilter) ([]domain.GetCashier, int64, error){ +func (s *Service) GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error) { + return s.userStore.GetAdminByCompanyID(ctx, companyID) +} +func (s *Service) GetAllCashiers(ctx context.Context, filter domain.UserFilter) ([]domain.GetCashier, int64, error) { return s.userStore.GetAllCashiers(ctx, filter) } diff --git a/internal/services/user/port.go b/internal/services/user/port.go index 992a381..0f2c6fa 100644 --- a/internal/services/user/port.go +++ b/internal/services/user/port.go @@ -14,6 +14,7 @@ type UserStore interface { GetAllCashiers(ctx context.Context, filter domain.UserFilter) ([]domain.GetCashier, int64, error) GetCashierByID(ctx context.Context, cashierID int64) (domain.GetCashier, error) GetCashiersByBranch(ctx context.Context, branchID int64) ([]domain.User, error) + GetAdminByCompanyID(ctx context.Context, companyID int64) (domain.User, error) UpdateUser(ctx context.Context, user domain.UpdateUserReq) error UpdateUserCompany(ctx context.Context, id int64, companyID int64) error UpdateUserSuspend(ctx context.Context, id int64, status bool) error diff --git a/internal/services/user/register.go b/internal/services/user/register.go index c7e0d83..4c8c003 100644 --- a/internal/services/user/register.go +++ b/internal/services/user/register.go @@ -10,7 +10,7 @@ import ( func (s *Service) CheckPhoneEmailExist(ctx context.Context, phoneNum, email string) (bool, bool, error) { // email,phone,error return s.userStore.CheckPhoneEmailExist(ctx, phoneNum, email) } -func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.OtpProvider) error { +func (s *Service) SendRegisterCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error { var err error // check if user exists switch medium { diff --git a/internal/services/user/reset.go b/internal/services/user/reset.go index 7c4e5d5..8834cb0 100644 --- a/internal/services/user/reset.go +++ b/internal/services/user/reset.go @@ -8,7 +8,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" ) -func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.OtpProvider) error { +func (s *Service) SendResetCode(ctx context.Context, medium domain.OtpMedium, sentTo string, provider domain.SMSProvider) error { var err error // check if user exists diff --git a/internal/services/user/service.go b/internal/services/user/service.go index 594a134..0ad970f 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -4,6 +4,7 @@ import ( "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/config" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/messenger" ) const ( @@ -11,19 +12,22 @@ const ( ) type Service struct { - userStore UserStore - otpStore OtpStore - config *config.Config + userStore UserStore + otpStore OtpStore + messengerSvc *messenger.Service + config *config.Config } func NewService( userStore UserStore, otpStore OtpStore, + messengerSvc *messenger.Service, cfg *config.Config, ) *Service { return &Service{ - userStore: userStore, - otpStore: otpStore, - config: cfg, + userStore: userStore, + otpStore: otpStore, + messengerSvc: messengerSvc, + config: cfg, } } diff --git a/internal/services/virtualGame/service.go b/internal/services/virtualGame/service.go index 6831945..ebc5311 100644 --- a/internal/services/virtualGame/service.go +++ b/internal/services/virtualGame/service.go @@ -254,7 +254,7 @@ func (s *service) ProcessBet(ctx context.Context, req *domain.PopOKBetRequest) ( return &domain.PopOKBetResponse{}, fmt.Errorf("Failed to read user wallets") } _, err = s.walletSvc.DeductFromWallet(ctx, claims.UserID, domain.Currency(amountCents), - domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT, + domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducted %v amount from wallet by system while placing virtual game bet", amountCents)) if err != nil { return nil, fmt.Errorf("insufficient balance") diff --git a/internal/services/virtualGame/veli/service.go b/internal/services/virtualGame/veli/service.go index b025392..de98686 100644 --- a/internal/services/virtualGame/veli/service.go +++ b/internal/services/virtualGame/veli/service.go @@ -115,7 +115,7 @@ func (c *Client) ProcessBet(ctx context.Context, req domain.BetRequest) (*domain return &domain.BetResponse{}, err } - c.walletSvc.DeductFromWallet(ctx, wallets[0].ID, domain.Currency(req.Amount.Amount), domain.CustomerWalletType, domain.ValidInt64{}, domain.TRANSFER_DIRECT, + c.walletSvc.DeductFromWallet(ctx, wallets[0].ID, domain.Currency(req.Amount.Amount), domain.ValidInt64{}, domain.TRANSFER_DIRECT, fmt.Sprintf("Deducting %v from wallet for creating Veli Game Bet", req.Amount.Amount), ) diff --git a/internal/services/wallet/monitor/service.go b/internal/services/wallet/monitor/service.go index e3f7bc5..e67ef6e 100644 --- a/internal/services/wallet/monitor/service.go +++ b/internal/services/wallet/monitor/service.go @@ -10,7 +10,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/branch" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" ) diff --git a/internal/services/wallet/port.go b/internal/services/wallet/port.go index d3a2954..89ee268 100644 --- a/internal/services/wallet/port.go +++ b/internal/services/wallet/port.go @@ -7,8 +7,8 @@ import ( ) type WalletStore interface { - // GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) - // GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) + GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) + GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) CreateWallet(ctx context.Context, wallet domain.CreateWallet) (domain.Wallet, error) CreateCustomerWallet(ctx context.Context, customerWallet domain.CreateCustomerWallet) (domain.CustomerWallet, error) GetWalletByID(ctx context.Context, id int64) (domain.Wallet, error) diff --git a/internal/services/wallet/service.go b/internal/services/wallet/service.go index 57b2cb9..319e3c3 100644 --- a/internal/services/wallet/service.go +++ b/internal/services/wallet/service.go @@ -3,8 +3,14 @@ package wallet import ( "log/slog" +<<<<<<< HEAD "github.com/SamuelTariku/FortuneBet-Backend/internal/services/kafka" notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" +======= + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + "go.uber.org/zap" +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f ) type Service struct { @@ -13,17 +19,33 @@ type Service struct { transferStore TransferStore notificationStore notificationservice.NotificationStore notificationSvc *notificationservice.Service + userSvc *user.Service + mongoLogger *zap.Logger logger *slog.Logger kafkaProducer *kafka.Producer } +<<<<<<< HEAD func NewService(walletStore WalletStore, transferStore TransferStore, notificationStore notificationservice.NotificationStore, notificationSvc *notificationservice.Service, logger *slog.Logger, kafkaProducer *kafka.Producer) *Service { +======= +func NewService( + walletStore WalletStore, + transferStore TransferStore, + notificationStore notificationservice.NotificationStore, + notificationSvc *notificationservice.Service, + userSvc *user.Service, + mongoLogger *zap.Logger, + logger *slog.Logger, +) *Service { +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f return &Service{ walletStore: walletStore, transferStore: transferStore, // approvalStore: approvalStore, notificationStore: notificationStore, notificationSvc: notificationSvc, + userSvc: userSvc, + mongoLogger: mongoLogger, logger: logger, kafkaProducer: kafkaProducer, } diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index b8d1b09..4dfc565 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -4,9 +4,14 @@ import ( "context" "errors" "fmt" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +<<<<<<< HEAD "github.com/SamuelTariku/FortuneBet-Backend/internal/event" +======= + "go.uber.org/zap" +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f ) var ( @@ -60,6 +65,14 @@ func (s *Service) GetWalletsByUser(ctx context.Context, id int64) ([]domain.Wall return s.walletStore.GetWalletsByUser(ctx, id) } +func (s *Service) GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) { + return s.walletStore.GetCompanyByWalletID(ctx, walletID) +} + +func (s *Service) GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) { + return s.walletStore.GetBranchByWalletID(ctx, walletID) +} + func (s *Service) GetAllCustomerWallet(ctx context.Context) ([]domain.GetCustomerWallet, error) { return s.walletStore.GetAllCustomerWallets(ctx) } @@ -77,11 +90,16 @@ func (s *Service) UpdateBalance(ctx context.Context, id int64, balance domain.Cu return err } +<<<<<<< HEAD wallet, err := s.walletStore.GetWalletByID(ctx, id) +======= + _, err = s.GetWalletByID(ctx, id) +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f if err != nil { return err } +<<<<<<< HEAD go func() { s.kafkaProducer.Publish(ctx, fmt.Sprint(wallet.ID), event.WalletEvent{ EventType: event.WalletBalanceUpdated, @@ -92,6 +110,9 @@ func (s *Service) UpdateBalance(ctx context.Context, id int64, balance domain.Cu }) }() +======= + // go s.notificationSvc.UpdateLiveWalletMetricForWallet(ctx, wallet) +>>>>>>> d43b12c589d32e4b6147cfb54a3b939c476bae6f return nil } @@ -135,7 +156,7 @@ func (s *Service) AddToWallet( return newTransfer, err } -func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain.Currency, walletType domain.WalletType, cashierID domain.ValidInt64, paymentMethod domain.PaymentMethod, message string) (domain.Transfer, error) { +func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain.Currency, cashierID domain.ValidInt64, paymentMethod domain.PaymentMethod, message string) (domain.Transfer, error) { wallet, err := s.GetWalletByID(ctx, id) if err != nil { return domain.Transfer{}, err @@ -143,12 +164,32 @@ func (s *Service) DeductFromWallet(ctx context.Context, id int64, amount domain. if wallet.Balance < amount { // Send Wallet low to admin - if walletType == domain.CompanyWalletType || walletType == domain.BranchWalletType { - s.SendAdminWalletLowNotification(ctx, wallet, amount) + if wallet.Type == domain.CompanyWalletType || wallet.Type == domain.BranchWalletType { + s.SendAdminWalletInsufficientNotification(ctx, wallet, amount) + } else { + s.SendCustomerWalletInsufficientNotification(ctx, wallet, amount) } return domain.Transfer{}, ErrBalanceInsufficient } + if wallet.Type == domain.BranchWalletType || wallet.Type == domain.CompanyWalletType { + var thresholds []float32 + + if wallet.Type == domain.CompanyWalletType { + thresholds = []float32{100000, 50000, 25000, 10000, 5000, 3000, 1000, 500} + } else { + thresholds = []float32{5000, 3000, 1000, 500} + } + + balance := wallet.Balance.Float32() + for _, threshold := range thresholds { + if balance < threshold { + s.SendAdminWalletLowNotification(ctx, wallet) + break // only send once per check + } + } + } + err = s.walletStore.UpdateBalance(ctx, id, wallet.Balance-amount) if err != nil { @@ -223,46 +264,208 @@ func (s *Service) UpdateWalletActive(ctx context.Context, id int64, isActive boo return s.walletStore.UpdateWalletActive(ctx, id, isActive) } -func (s *Service) SendAdminWalletLowNotification(ctx context.Context, adminWallet domain.Wallet, amount domain.Currency) error { +func (s *Service) GetAdminNotificationRecipients(ctx context.Context, walletID int64, walletType domain.WalletType) ([]int64, error) { + var recipients []int64 + + if walletType == domain.BranchWalletType { + branch, err := s.GetBranchByWalletID(ctx, walletID) + if err != nil { + return nil, err + } + recipients = append(recipients, branch.BranchManagerID) + + cashiers, err := s.userSvc.GetCashiersByBranch(ctx, branch.ID) + if err != nil { + return nil, err + } + for _, cashier := range cashiers { + recipients = append(recipients, cashier.ID) + } + + admin, err := s.userSvc.GetAdminByCompanyID(ctx, branch.CompanyID) + if err != nil { + return nil, err + } + recipients = append(recipients, admin.ID) + + } else if walletType == domain.CompanyWalletType { + company, err := s.GetCompanyByWalletID(ctx, walletID) + if err != nil { + return nil, err + } + recipients = append(recipients, company.AdminID) + } else { + return nil, fmt.Errorf("Invalid wallet type") + } + + users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ + Role: string(domain.RoleSuperAdmin), + }) + + if err != nil { + return nil, err + } + + for _, user := range users { + recipients = append(recipients, user.ID) + } + + return recipients, nil +} + +func (s *Service) SendAdminWalletLowNotification(ctx context.Context, adminWallet domain.Wallet) error { // Send notification to admin team adminNotification := &domain.Notification{ RecipientID: adminWallet.UserID, Type: domain.NOTIFICATION_TYPE_ADMIN_ALERT, - Level: domain.NotificationLevelError, + Level: domain.NotificationLevelWarning, Reciever: domain.NotificationRecieverSideAdmin, - DeliveryChannel: domain.DeliveryChannelEmail, // Or any preferred admin channel + DeliveryChannel: domain.DeliveryChannelInApp, // Or any preferred admin channel Payload: domain.NotificationPayload{ Headline: "CREDIT WARNING: System Running Out of Funds", Message: fmt.Sprintf( - "Wallet ID %d has insufficient balance for transfer. Current balance: %.2f, Attempted transfer: %.2f", + "Wallet ID %d is running low. Current balance: %.2f", adminWallet.ID, adminWallet.Balance.Float32(), - amount.Float32(), ), }, Priority: 1, // High priority for admin alerts Metadata: fmt.Appendf(nil, `{ "wallet_id": %d, "balance": %d, - "required_amount": %d, "notification_type": "admin_alert" - }`, adminWallet.ID, adminWallet.Balance, amount), + }`, adminWallet.ID, adminWallet.Balance), } // Get admin recipients and send to all - adminRecipients, err := s.notificationStore.ListRecipientIDs(ctx, domain.NotificationRecieverSideAdmin) + adminRecipients, err := s.GetAdminNotificationRecipients(ctx, adminWallet.ID, adminWallet.Type) if err != nil { - s.logger.Error("failed to get admin recipients", "error", err) + s.mongoLogger.Error("failed to get admin recipients", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) return err - } else { - for _, adminID := range adminRecipients { - adminNotification.RecipientID = adminID - if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { - s.logger.Error("failed to send admin notification", - "admin_id", adminID, - "error", err) - } + } + + for _, adminID := range adminRecipients { + adminNotification.RecipientID = adminID + if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { + s.mongoLogger.Error("failed to send admin notification", + zap.Int64("admin_id", adminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } + + adminNotification.DeliveryChannel = domain.DeliveryChannelEmail + + if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { + s.mongoLogger.Error("failed to send email admin notification", + zap.Int64("admin_id", adminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err } } return nil } + +func (s *Service) SendAdminWalletInsufficientNotification(ctx context.Context, adminWallet domain.Wallet, amount domain.Currency) error { + + // Send notification to admin team + adminNotification := &domain.Notification{ + RecipientID: adminWallet.UserID, + Type: domain.NOTIFICATION_TYPE_ADMIN_ALERT, + Level: domain.NotificationLevelError, + Reciever: domain.NotificationRecieverSideAdmin, + DeliveryChannel: domain.DeliveryChannelInApp, // Or any preferred admin channel + Payload: domain.NotificationPayload{ + Headline: "CREDIT Error: Admin Wallet insufficient to process customer request", + Message: fmt.Sprintf( + "Wallet ID %d. Transaction Amount %.2f. Current balance: %.2f", + adminWallet.ID, + amount.Float32(), + adminWallet.Balance.Float32(), + ), + }, + Priority: 1, // High priority for admin alerts + Metadata: fmt.Appendf(nil, `{ + "wallet_id": %d, + "balance": %d, + "transaction amount": %.2f, + "notification_type": "admin_alert" + }`, adminWallet.ID, adminWallet.Balance, amount.Float32()), + } + + // Get admin recipients and send to all + + recipients, err := s.GetAdminNotificationRecipients(ctx, adminWallet.ID, adminWallet.Type) + if err != nil { + s.mongoLogger.Error("failed to get admin recipients", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + for _, adminID := range recipients { + adminNotification.RecipientID = adminID + if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { + s.mongoLogger.Error("failed to send admin notification", + zap.Int64("admin_id", adminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + } + adminNotification.DeliveryChannel = domain.DeliveryChannelEmail + + if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { + s.mongoLogger.Error("failed to send email admin notification", + zap.Int64("admin_id", adminID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + } + return nil +} + +func (s *Service) SendCustomerWalletInsufficientNotification(ctx context.Context, customerWallet domain.Wallet, amount domain.Currency) error { + // Send notification to admin team + customerNotification := &domain.Notification{ + RecipientID: customerWallet.UserID, + Type: domain.NOTIFICATION_TYPE_WALLET, + Level: domain.NotificationLevelError, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelInApp, // Or any preferred admin channel + Payload: domain.NotificationPayload{ + Headline: "CREDIT Error: Wallet insufficient", + Message: fmt.Sprintf( + "Wallet ID %d. Transaction Amount %.2f. Current balance: %.2f", + customerWallet.ID, + amount.Float32(), + customerWallet.Balance.Float32(), + ), + }, + Priority: 1, // High priority for admin alerts + Metadata: fmt.Appendf(nil, `{ + "wallet_id": %d, + "balance": %d, + "transaction amount": %.2f, + "notification_type": "admin_alert" + }`, customerWallet.ID, customerWallet.Balance, amount.Float32()), + } + + if err := s.notificationStore.SendNotification(ctx, customerNotification); err != nil { + s.mongoLogger.Error("failed to create customer notification", + zap.Int64("customer_id", customerWallet.UserID), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + return nil +} diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 201daeb..92a884c 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -34,7 +34,7 @@ import ( customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" "go.uber.org/zap" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 56565af..632c6e3 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -24,22 +24,22 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - // { - // spec: "0 0 * * * *", // Every 1 hour - // task: func() { - // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - // log.Printf("FetchUpcomingEvents error: %v", err) - // } - // }, - // }, - // { - // spec: "0 0 * * * *", // Every 1 hour (since its takes that long to fetch all the events) - // task: func() { - // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - // log.Printf("FetchNonLiveOdds error: %v", err) - // } - // }, - // }, + { + spec: "0 0 * * * *", // Every 1 hour + task: func() { + if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + log.Printf("FetchUpcomingEvents error: %v", err) + } + }, + }, + { + spec: "0 0 * * * *", // Every 1 hour (since its takes that long to fetch all the events) + task: func() { + if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + log.Printf("FetchNonLiveOdds error: %v", err) + } + }, + }, { spec: "0 */5 * * * *", // Every 5 Minutes task: func() { diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 9507669..6d92b98 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" @@ -14,7 +15,7 @@ import ( // loginCustomerReq represents the request body for the LoginCustomer endpoint. type loginCustomerReq struct { - Email string `json:"email" validate:"email" example:"john.doe@example.com"` + Email string `json:"email" validate:"required_without=PhoneNumber" example:"john.doe@example.com"` PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` Password string `json:"password" validate:"required" example:"password123"` } @@ -37,7 +38,7 @@ type loginCustomerRes struct { // @Failure 400 {object} response.APIResponse // @Failure 401 {object} response.APIResponse // @Failure 500 {object} response.APIResponse -// @Router /api/v1/auth/login [post] +// @Router /api/v1/auth/customer-login [post] func (h *Handler) LoginCustomer(c *fiber.Ctx) error { var req loginCustomerReq if err := c.BodyParser(&req); err != nil { @@ -59,7 +60,6 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error { successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) if err != nil { - switch { case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): h.mongoLoggerSvc.Info("Login attempt failed: Invalid credentials", @@ -89,6 +89,133 @@ func (h *Handler) LoginCustomer(c *fiber.Ctx) error { } } + if successRes.Role != domain.RoleCustomer { + h.mongoLoggerSvc.Info("Login attempt: customer login of other role", + zap.Int("status_code", fiber.StatusForbidden), + zap.String("role", string(successRes.Role)), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusForbidden, "Only customers are allowed to login ") + } + + accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) + if err != nil { + h.mongoLoggerSvc.Error("Failed to create access token", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Int64("user_id", successRes.UserId), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate access token") + } + + res := loginCustomerRes{ + AccessToken: accessToken, + RefreshToken: successRes.RfToken, + Role: string(successRes.Role), + } + + h.mongoLoggerSvc.Info("Login successful", + zap.Int("status_code", fiber.StatusOK), + zap.Int64("user_id", successRes.UserId), + zap.String("role", string(successRes.Role)), + zap.Time("timestamp", time.Now()), + ) + + return response.WriteJSON(c, fiber.StatusOK, "Login successful", res, nil) +} + +// loginAdminReq represents the request body for the LoginAdmin endpoint. +type loginAdminReq struct { + Email string `json:"email" validate:"email" example:"john.doe@example.com"` + PhoneNumber string `json:"phone_number" validate:"required_without=Email" example:"1234567890"` + Password string `json:"password" validate:"required" example:"password123"` +} + +// loginAdminRes represents the response body for the LoginAdmin endpoint. +type loginAdminRes struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Role string `json:"role"` +} + +// LoginAdmin godoc +// @Summary Login customer +// @Description Login customer +// @Tags auth +// @Accept json +// @Produce json +// @Param login body loginAdminReq true "Login admin" +// @Success 200 {object} loginAdminRes +// @Failure 400 {object} response.APIResponse +// @Failure 401 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/auth/admin-login [post] +func (h *Handler) LoginAdmin(c *fiber.Ctx) error { + var req loginAdminReq + if err := c.BodyParser(&req); err != nil { + h.mongoLoggerSvc.Info("Failed to parse LoginAdmin request", + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body"+err.Error()) + } + + if valErrs, ok := h.validator.Validate(c, req); !ok { + var errMsg string + for field, msg := range valErrs { + errMsg += fmt.Sprintf("%s: %s; ", field, msg) + } + return fiber.NewError(fiber.StatusBadRequest, errMsg) + } + + successRes, err := h.authSvc.Login(c.Context(), req.Email, req.PhoneNumber, req.Password) + if err != nil { + switch { + case errors.Is(err, authentication.ErrInvalidPassword), errors.Is(err, authentication.ErrUserNotFound): + h.mongoLoggerSvc.Info("Login attempt failed: Invalid credentials", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusUnauthorized, "Invalid credentials") + case errors.Is(err, authentication.ErrUserSuspended): + h.mongoLoggerSvc.Info("Login attempt failed: User login has been locked", + zap.Int("status_code", fiber.StatusUnauthorized), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusUnauthorized, "User login has been locked") + default: + h.mongoLoggerSvc.Error("Login failed", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Internal server error") + } + } + + if successRes.Role == domain.RoleCustomer { + h.mongoLoggerSvc.Warn("Login attempt: admin login of customer", + zap.Int("status_code", fiber.StatusForbidden), + zap.String("role", string(successRes.Role)), + zap.String("email", req.Email), + zap.String("phone", req.PhoneNumber), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusForbidden, "Only admin roles are allowed") + } + accessToken, err := jwtutil.CreateJwt(successRes.UserId, successRes.Role, successRes.CompanyID, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry) if err != nil { h.mongoLoggerSvc.Error("Failed to create access token", diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 0c335a6..a323560 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -45,6 +45,7 @@ func (h *Handler) CreateBet(c *fiber.Ctx) error { zap.Int("status_code", fiber.StatusInternalServerError), zap.Int64("user_id", userID), zap.String("role", string(role)), + zap.Error(err), zap.Time("timestamp", time.Now()), ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to create bet:"+err.Error()) @@ -97,6 +98,15 @@ func (h *Handler) CreateBetWithFastCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "failed to get bet with fast code:"+err.Error()) } + if bet.UserID == userID { + h.mongoLoggerSvc.Info("User cannot refer himself", + zap.Int64("bet_id", bet.ID), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Time("timestamp", time.Now()), + zap.Error(err), + ) + return fiber.NewError(fiber.StatusBadRequest, "User cannot use his own referral code") + } outcomes, err := h.betSvc.GetBetOutcomeByBetID(c.Context(), bet.ID) if err != nil { h.mongoLoggerSvc.Info("failed to get BetOutcomes by BetID", @@ -118,7 +128,7 @@ func (h *Handler) CreateBetWithFastCode(c *fiber.Ctx) error { } // This can be for both online and offline bets - // If bet is an online bet (if the customer role creates the bet on their own) + // If bet is an online bet (if the customer role creates the bet on their own) // then the branchID is null newReq := domain.CreateBetReq{ Amount: req.Amount, @@ -190,7 +200,7 @@ func (h *Handler) CreateBetInternal(c *fiber.Ctx, req domain.CreateBetReq, userI zap.Error(err), zap.Time("timestamp", time.Now()), ) - return domain.CreateBetRes{}, fiber.NewError(fiber.StatusBadRequest, err.Error()) + return domain.CreateBetRes{}, err } h.mongoLoggerSvc.Error("PlaceBet failed", @@ -202,7 +212,7 @@ func (h *Handler) CreateBetInternal(c *fiber.Ctx, req domain.CreateBetReq, userI zap.Time("timestamp", time.Now()), ) - return domain.CreateBetRes{}, fiber.NewError(fiber.StatusInternalServerError, "Unable to create bet") + return domain.CreateBetRes{}, err } return res, nil @@ -490,6 +500,42 @@ func (h *Handler) GetBetByID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) } +// GetBetByFastCode godoc +// @Summary Gets bet by fast_code +// @Description Gets a single bet by fast_code +// @Tags bet +// @Accept json +// @Produce json +// @Param fast_code path int true "Bet ID" +// @Success 200 {object} domain.BetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/sport/bet/fastcode/{fast_code} [get] +func (h *Handler) GetBetByFastCode(c *fiber.Ctx) error { + fastCode := c.Params("fast_code") + + bet, err := h.betSvc.GetBetByFastCode(c.Context(), fastCode) + if err != nil { + h.mongoLoggerSvc.Info("Failed to get bet by fast code", + zap.String("fast_code", fastCode), + zap.Int("status_code", fiber.StatusNotFound), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusNotFound, "Failed to find bet by fast code") + } + + res := domain.ConvertBet(bet) + + // h.mongoLoggerSvc.Info("Bet retrieved successfully", + // zap.Int64("betID", id), + // zap.Int("status_code", fiber.StatusOK), + // zap.Time("timestamp", time.Now()), + // ) + + return response.WriteJSON(c, fiber.StatusOK, "Bet retrieved successfully", res, nil) +} + type UpdateCashOutReq struct { CashedOut bool } diff --git a/internal/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index 4f42491..f73c20d 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -99,12 +99,13 @@ func (h *Handler) CreateBranch(c *fiber.Ctx) error { } branch, err := h.branchSvc.CreateBranch(c.Context(), domain.CreateBranch{ - Name: req.Name, - Location: req.Location, - WalletID: newWallet.ID, - BranchManagerID: req.BranchManagerID, - CompanyID: checkedCompanyID, - IsSelfOwned: IsSelfOwned, + Name: req.Name, + Location: req.Location, + WalletID: newWallet.ID, + BranchManagerID: req.BranchManagerID, + CompanyID: checkedCompanyID, + IsSelfOwned: IsSelfOwned, + ProfitPercentage: req.ProfitPercentage, }) if err != nil { @@ -619,6 +620,38 @@ func (h *Handler) GetBranchOperations(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Branch Operations retrieved successfully", result, nil) } +// GetAllBranchLocations godoc +// @Summary Gets all branch locations +// @Description Gets all branch locations +// @Tags branch +// @Accept json +// @Produce json +// @Success 200 {array} domain.BranchLocation +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/branchLocation [get] +func (h *Handler) GetAllBranchLocations(c *fiber.Ctx) error { + + searchQuery := c.Query("query") + searchString := domain.ValidString{ + Value: searchQuery, + Valid: searchQuery != "", + } + + locations, err := h.branchSvc.GetAllBranchLocations(c.Context(), searchString) + + if err != nil { + h.mongoLoggerSvc.Error("Failed to get branch locations", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return response.WriteJSON(c, fiber.StatusOK, "Branch Location successfully fetched", locations, nil) +} + // GetBranchCashiers godoc // @Summary Gets branch cashiers // @Description Gets branch cashiers diff --git a/internal/web_server/handlers/company_handler.go b/internal/web_server/handlers/company_handler.go index d7c8551..8eb731c 100644 --- a/internal/web_server/handlers/company_handler.go +++ b/internal/web_server/handlers/company_handler.go @@ -50,6 +50,7 @@ func (h *Handler) CreateCompany(c *fiber.Ctx) error { user, err := h.userSvc.GetUserByID(c.Context(), req.AdminID) if err != nil { h.mongoLoggerSvc.Error("Error fetching user", + zap.Int("admin_id", int(req.AdminID)), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), zap.Time("timestamp", time.Now()), @@ -76,9 +77,10 @@ func (h *Handler) CreateCompany(c *fiber.Ctx) error { } company, err := h.companySvc.CreateCompany(c.Context(), domain.CreateCompany{ - Name: req.Name, - AdminID: user.ID, - WalletID: newWallet.ID, + Name: req.Name, + AdminID: user.ID, + WalletID: newWallet.ID, + DeductedPercentage: req.DeductedPercentage, }) if err != nil { diff --git a/internal/web_server/handlers/event_handler.go b/internal/web_server/handlers/event_handler.go index 2695332..9fa7940 100644 --- a/internal/web_server/handlers/event_handler.go +++ b/internal/web_server/handlers/event_handler.go @@ -74,6 +74,13 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { Valid: true, } } + + searchQuery := c.Query("query") + searchString := domain.ValidString{ + Value: searchQuery, + Valid: searchQuery != "", + } + firstStartTimeQuery := c.Query("first_start_time") var firstStartTime domain.ValidTime if firstStartTimeQuery != "" { @@ -98,7 +105,7 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { if lastStartTimeQuery != "" { lastStartTimeParsed, err := time.Parse(time.RFC3339, lastStartTimeQuery) if err != nil { - h.mongoLoggerSvc.Info("invalid start_time format", + h.mongoLoggerSvc.Info("invalid last_start_time format", zap.String("last_start_time", lastStartTimeQuery), zap.Int("status_code", fiber.StatusBadRequest), zap.Error(err), @@ -118,12 +125,12 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { Valid: countryCodeQuery != "", } - flaggedQuery := c.Query("flagged") - var flagged domain.ValidBool - if flaggedQuery != "" { - flaggedParsed, err := strconv.ParseBool(flaggedQuery) + isFeaturedQuery := c.Query("is_featured") + var isFeatured domain.ValidBool + if isFeaturedQuery != "" { + isFeaturedParsed, err := strconv.ParseBool(isFeaturedQuery) if err != nil { - h.mongoLoggerSvc.Error("Failed to parse flagged", + h.mongoLoggerSvc.Error("Failed to parse isFeatured", zap.Int("status_code", fiber.StatusBadRequest), zap.Error(err), zap.Time("timestamp", time.Now()), @@ -131,8 +138,8 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Failed to parse is_shop_bet") } - flagged = domain.ValidBool{ - Value: flaggedParsed, + isFeatured = domain.ValidBool{ + Value: isFeaturedParsed, Valid: true, } } @@ -141,12 +148,13 @@ func (h *Handler) GetAllUpcomingEvents(c *fiber.Ctx) error { c.Context(), domain.EventFilter{ SportID: sportID, LeagueID: leagueID, + Query: searchString, FirstStartTime: firstStartTime, LastStartTime: lastStartTime, Limit: limit, Offset: offset, CountryCode: countryCode, - Flagged: flagged, + Featured: isFeatured, }) // fmt.Printf("League ID: %v", leagueID) @@ -299,13 +307,13 @@ func (h *Handler) SetEventStatusToRemoved(c *fiber.Ctx) error { } -type UpdateEventFlaggedReq struct { - Flagged bool `json:"flagged" example:"true"` +type UpdateEventFeaturedReq struct { + Featured bool `json:"is_featured" example:"true"` } -// UpdateEventFlagged godoc -// @Summary update the event flagged -// @Description Update the event flagged +// UpdateEventFeatured godoc +// @Summary update the event featured +// @Description Update the event featured // @Tags event // @Accept json // @Produce json @@ -314,10 +322,10 @@ type UpdateEventFlaggedReq struct { // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Router /api/v1/events/{id}/flag [put] -func (h *Handler) UpdateEventFlagged(c *fiber.Ctx) error { +func (h *Handler) UpdateEventFeatured(c *fiber.Ctx) error { eventID := c.Params("id") - var req UpdateEventFlaggedReq + var req UpdateEventFeaturedReq if err := c.BodyParser(&req); err != nil { h.mongoLoggerSvc.Info("Failed to parse user id", @@ -335,17 +343,17 @@ func (h *Handler) UpdateEventFlagged(c *fiber.Ctx) error { for field, msg := range valErrs { errMsg += fmt.Sprintf("%s: %s; ", field, msg) } - h.mongoLoggerSvc.Error("Failed to update event flagged", + h.mongoLoggerSvc.Error("Failed to update event featured", zap.Any("request", req), zap.Int("status_code", fiber.StatusInternalServerError), zap.Time("timestamp", time.Now()), ) return fiber.NewError(fiber.StatusBadRequest, errMsg) } - err := h.eventSvc.UpdateFlagged(c.Context(), eventID, req.Flagged) + err := h.eventSvc.UpdateFeatured(c.Context(), eventID, req.Featured) if err != nil { - h.mongoLoggerSvc.Error("Failed to update event flagged", + h.mongoLoggerSvc.Error("Failed to update event featured", zap.String("eventID", eventID), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index b964817..deeea33 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -16,7 +16,7 @@ import ( "github.com/SamuelTariku/FortuneBet-Backend/internal/services/institutions" issuereporting "github.com/SamuelTariku/FortuneBet-Backend/internal/services/issue_reporting" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/league" - notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notfication" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/recommendation" referralservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/referal" diff --git a/internal/web_server/handlers/issue_reporting.go b/internal/web_server/handlers/issue_reporting.go index 8cc45c9..0dd334e 100644 --- a/internal/web_server/handlers/issue_reporting.go +++ b/internal/web_server/handlers/issue_reporting.go @@ -115,7 +115,23 @@ func (h *Handler) GetAllIssues(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get all issues:"+err.Error()) } - return c.JSON(issues) + results := make([]domain.ReportedIssue, len(issues)) + for i, issue := range issues { + results[i] = domain.ReportedIssue{ + ID: issue.ID, + UserID: issue.UserID, + UserRole: domain.Role(issue.UserRole), + Subject: issue.Subject, + Description: issue.Description, + IssueType: domain.ReportedIssueType(issue.IssueType), + Status: domain.ReportedIssueStatus(issue.Status), + // Metadata: issue.Metadata, + CreatedAt: issue.CreatedAt.Time, + UpdatedAt: issue.UpdatedAt.Time, + } + } + + return c.JSON(results) } // UpdateIssueStatus godoc diff --git a/internal/web_server/handlers/leagues.go b/internal/web_server/handlers/leagues.go index 794bac2..09b63a1 100644 --- a/internal/web_server/handlers/leagues.go +++ b/internal/web_server/handlers/leagues.go @@ -140,7 +140,7 @@ func (h *Handler) SetLeagueActive(c *fiber.Ctx) error { if err := h.leagueSvc.SetLeagueActive(c.Context(), int64(leagueId), req.IsActive); err != nil { h.mongoLoggerSvc.Error("Failed to update league active", - zap.Int64("userID", int64(leagueId)), + zap.Int64("leagueID", int64(leagueId)), zap.Bool("is_active", req.IsActive), zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), @@ -149,6 +149,14 @@ func (h *Handler) SetLeagueActive(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update league:"+err.Error()) } + h.mongoLoggerSvc.Info("League Active has been successfully updated", + zap.Int64("userID", int64(leagueId)), + zap.Int64("leagueID", int64(leagueId)), + zap.Bool("is_active", req.IsActive), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) + return response.WriteJSON(c, fiber.StatusOK, "League updated successfully", nil, nil) } @@ -206,6 +214,10 @@ func (h *Handler) SetLeagueFeatured(c *fiber.Ctx) error { } err = h.leagueSvc.UpdateLeague(c.Context(), domain.UpdateLeague{ ID: int64(leagueId), + IsFeatured: domain.ValidBool{ + Value: req.IsFeatured, + Valid: true, + }, }) if err != nil { h.mongoLoggerSvc.Error("Failed to update league", @@ -216,6 +228,12 @@ func (h *Handler) SetLeagueFeatured(c *fiber.Ctx) error { ) return fiber.NewError(fiber.StatusInternalServerError, "Failed to update league:"+err.Error()) } - + h.mongoLoggerSvc.Info("League Featured has been successfully updated", + zap.Int64("userID", int64(leagueId)), + zap.Int64("leagueID", int64(leagueId)), + zap.Bool("is_featured", req.IsFeatured), + zap.Int("status_code", fiber.StatusOK), + zap.Time("timestamp", time.Now()), + ) return response.WriteJSON(c, fiber.StatusOK, "League updated successfully", nil, nil) } diff --git a/internal/web_server/handlers/mongoLogger.go b/internal/web_server/handlers/mongoLogger.go index 2ccc7a2..6655ae3 100644 --- a/internal/web_server/handlers/mongoLogger.go +++ b/internal/web_server/handlers/mongoLogger.go @@ -99,7 +99,7 @@ func GetLogsHandler(appCtx context.Context) fiber.Handler { } defer cursor.Close(appCtx) - var logs []domain.LogEntry + var logs []domain.LogEntry = make([]domain.LogEntry, 0) if err := cursor.All(appCtx, &logs); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Cursor decoding error: "+err.Error()) } @@ -113,7 +113,7 @@ func GetLogsHandler(appCtx context.Context) fiber.Handler { // Prepare response response := domain.LogResponse{ Message: "Logs fetched successfully", - Data: logs, + Data: logs, Pagination: domain.Pagination{ Total: int(total), TotalPages: totalPages, diff --git a/internal/web_server/handlers/notification_handler.go b/internal/web_server/handlers/notification_handler.go index 4a5e106..e278581 100644 --- a/internal/web_server/handlers/notification_handler.go +++ b/internal/web_server/handlers/notification_handler.go @@ -117,7 +117,7 @@ func (h *Handler) ConnectSocket(c *fiber.Ctx) error { zap.Time("timestamp", time.Now()), ) } else { - h.mongoLoggerSvc.Warn("Unexpected WebSocket closure", + h.mongoLoggerSvc.Info("Unexpected WebSocket closure", zap.Int64("userID", userID), zap.Int("status_code", fiber.StatusBadRequest), zap.Error(err), diff --git a/internal/web_server/handlers/report.go b/internal/web_server/handlers/report.go index e15faae..63308da 100644 --- a/internal/web_server/handlers/report.go +++ b/internal/web_server/handlers/report.go @@ -35,11 +35,13 @@ import ( // @Failure 500 {object} domain.ErrorResponse // @Router /api/v1/reports/dashboard [get] func (h *Handler) GetDashboardReport(c *fiber.Ctx) error { + role := c.Locals("role").(domain.Role) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Parse query parameters - filter, err := parseReportFilter(c) + filter, err := parseReportFilter(c, role) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{ Message: "Invalid filter parameters", @@ -70,24 +72,30 @@ func (h *Handler) GetDashboardReport(c *fiber.Ctx) error { } // parseReportFilter parses query parameters into ReportFilter -func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) { +func parseReportFilter(c *fiber.Ctx, role domain.Role) (domain.ReportFilter, error) { var filter domain.ReportFilter var err error - if c.Query("company_id") != "" { + if c.Query("company_id") != "" && role == domain.RoleSuperAdmin { + companyID, err := strconv.ParseInt(c.Query("company_id"), 10, 64) if err != nil { return domain.ReportFilter{}, fmt.Errorf("invalid company_id: %w", err) } filter.CompanyID = domain.ValidInt64{Value: companyID, Valid: true} + } else { + filter.CompanyID = c.Locals("company_id").(domain.ValidInt64) + } - if c.Query("branch_id") != "" { + if c.Query("branch_id") != "" && role == domain.RoleSuperAdmin { branchID, err := strconv.ParseInt(c.Query("branch_id"), 10, 64) if err != nil { return domain.ReportFilter{}, fmt.Errorf("invalid branch_id: %w", err) } filter.BranchID = domain.ValidInt64{Value: branchID, Valid: true} + } else { + filter.BranchID = c.Locals("branch_id").(domain.ValidInt64) } if c.Query("user_id") != "" { diff --git a/internal/web_server/handlers/shop_handler.go b/internal/web_server/handlers/shop_handler.go index a81de8f..f63341f 100644 --- a/internal/web_server/handlers/shop_handler.go +++ b/internal/web_server/handlers/shop_handler.go @@ -116,6 +116,89 @@ func (h *Handler) GetShopBetByBetID(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Shop bet fetched successfully", res, nil) } +// GetAllShopBets godoc +// @Summary Gets all shop bets +// @Description Gets all the shop bets +// @Tags bet +// @Accept json +// @Produce json +// @Success 200 {array} domain.ShopBetRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /api/v1/shop/bet [get] +func (h *Handler) GetAllShopBets(c *fiber.Ctx) error { + // role := c.Locals("role").(domain.Role) + companyID := c.Locals("company_id").(domain.ValidInt64) + branchID := c.Locals("branch_id").(domain.ValidInt64) + + searchQuery := c.Query("query") + searchString := domain.ValidString{ + Value: searchQuery, + Valid: searchQuery != "", + } + + createdBeforeQuery := c.Query("created_before") + var createdBefore domain.ValidTime + if createdBeforeQuery != "" { + createdBeforeParsed, err := time.Parse(time.RFC3339, createdBeforeQuery) + if err != nil { + h.mongoLoggerSvc.Info("invalid created_before format", + zap.String("time", createdBeforeQuery), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusBadRequest, "Invalid created_before format") + } + createdBefore = domain.ValidTime{ + Value: createdBeforeParsed, + Valid: true, + } + } + + createdAfterQuery := c.Query("created_after") + var createdAfter domain.ValidTime + if createdAfterQuery != "" { + createdAfterParsed, err := time.Parse(time.RFC3339, createdAfterQuery) + if err != nil { + h.mongoLoggerSvc.Info("invalid created_after format", + zap.String("created_after", createdAfterQuery), + zap.Int("status_code", fiber.StatusBadRequest), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusBadRequest, "Invalid created_after format") + } + createdAfter = domain.ValidTime{ + Value: createdAfterParsed, + Valid: true, + } + } + + bets, err := h.transactionSvc.GetAllShopBet(c.Context(), domain.ShopBetFilter{ + Query: searchString, + CreatedBefore: createdBefore, + CreatedAfter: createdAfter, + CompanyID: companyID, + BranchID: branchID, + }) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get all bets", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve bets፡"+err.Error()) + } + + res := make([]domain.ShopBetRes, len(bets)) + for i, bet := range bets { + res[i] = domain.ConvertShopBetDetail(bet) + } + + return response.WriteJSON(c, fiber.StatusOK, "All bets retrieved successfully", res, nil) +} + // CashoutBet godoc // @Summary Cashout bet at branch // @Description Cashout bet at branch diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 5344919..8779de9 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -120,7 +120,7 @@ func (h *Handler) SendRegisterCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") } - if err := h.userSvc.SendRegisterCode(c.Context(), medium, sentTo, "twilio"); err != nil { + if err := h.userSvc.SendRegisterCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil { h.mongoLoggerSvc.Error("Failed to send register code", zap.String("Medium", string(medium)), zap.String("Send To", string(sentTo)), @@ -248,7 +248,7 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error { // TODO: Remove later _, err = h.walletSvc.AddToWallet( c.Context(), newWallet.RegularID, domain.ToCurrency(10000.0), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, - "Added 100.0 to wallet only as test for deployment") + "Added 10000.0 to wallet only as test for deployment") if err != nil { h.mongoLoggerSvc.Error("Failed to update wallet for user", @@ -318,7 +318,7 @@ func (h *Handler) SendResetCode(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Email or PhoneNumber must be provided") } - if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, "twilio"); err != nil { + if err := h.userSvc.SendResetCode(c.Context(), medium, sentTo, domain.AfroMessage); err != nil { h.mongoLoggerSvc.Error("Failed to send reset code", zap.String("medium", string(medium)), zap.String("sentTo", string(sentTo)), @@ -417,20 +417,121 @@ type UserProfileRes struct { LastLogin time.Time `json:"last_login"` SuspendedAt time.Time `json:"suspended_at"` Suspended bool `json:"suspended"` + ReferralCode string `json:"referral_code"` } -// UserProfile godoc +type CustomerProfileRes struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role domain.Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLogin time.Time `json:"last_login"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` + ReferralCode string `json:"referral_code"` +} + +// CustomerProfile godoc // @Summary Get user profile // @Description Get user profile // @Tags user // @Accept json // @Produce json -// @Success 200 {object} UserProfileRes +// @Success 200 {object} CustomerProfileRes // @Failure 400 {object} response.APIResponse // @Failure 500 {object} response.APIResponse // @Security Bearer -// @Router /api/v1/user/profile [get] -func (h *Handler) UserProfile(c *fiber.Ctx) error { +// @Router /api/v1/user/customer-profile [get] +func (h *Handler) CustomerProfile(c *fiber.Ctx) error { + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.mongoLoggerSvc.Error("Invalid user ID in context", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification") + } + + user, err := h.userSvc.GetUserByID(c.Context(), userID) + if err != nil { + h.mongoLoggerSvc.Error("Failed to get user profile", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user profile:"+err.Error()) + } + + lastLogin, err := h.authSvc.GetLastLogin(c.Context(), user.ID) + if err != nil { + if err != authentication.ErrRefreshTokenNotFound { + h.mongoLoggerSvc.Error("Failed to get user last login", + zap.Int64("userID", userID), + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve user last login:"+err.Error()) + } + + lastLogin = &user.CreatedAt + } + res := CustomerProfileRes{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Email: user.Email, + PhoneNumber: user.PhoneNumber, + Role: user.Role, + EmailVerified: user.EmailVerified, + PhoneVerified: user.PhoneVerified, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + SuspendedAt: user.SuspendedAt, + Suspended: user.Suspended, + LastLogin: *lastLogin, + + } + return response.WriteJSON(c, fiber.StatusOK, "User profile retrieved successfully", res, nil) +} + +type AdminProfileRes struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + Role domain.Role `json:"role"` + EmailVerified bool `json:"email_verified"` + PhoneVerified bool `json:"phone_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLogin time.Time `json:"last_login"` + SuspendedAt time.Time `json:"suspended_at"` + Suspended bool `json:"suspended"` +} + +// AdminProfile godoc +// @Summary Get user profile +// @Description Get user profile +// @Tags user +// @Accept json +// @Produce json +// @Success 200 {object} AdminProfileRes +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Security Bearer +// @Router /api/v1/user/admin-profile [get] +func (h *Handler) AdminProfile(c *fiber.Ctx) error { userID, ok := c.Locals("user_id").(int64) if !ok || userID == 0 { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d9df30d..3a4083b 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -56,7 +56,7 @@ func (a *App) initAppRoutes() { a.fiber.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "Welcome to the FortuneBet API", - "version": "1.0dev10", + "version": "1.0dev11", }) }) @@ -67,12 +67,13 @@ func (a *App) initAppRoutes() { groupV1.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "FortuneBet API V1 pre-alpha", - "version": "1.0dev10", + "version": "1.0dev11", }) }) // Auth Routes - groupV1.Post("/auth/login", h.LoginCustomer) + groupV1.Post("/auth/customer-login", h.LoginCustomer) + groupV1.Post("/auth/admin-login", h.LoginAdmin) groupV1.Post("/auth/refresh", h.RefreshToken) groupV1.Post("/auth/logout", a.authMiddleware, h.LogOutCustomer) groupV1.Get("/auth/test", a.authMiddleware, func(c *fiber.Ctx) error { @@ -113,7 +114,8 @@ func (a *App) initAppRoutes() { groupV1.Post("/user/register", h.RegisterUser) groupV1.Post("/user/sendRegisterCode", h.SendRegisterCode) groupV1.Post("/user/checkPhoneEmailExist", h.CheckPhoneEmailExist) - groupV1.Get("/user/profile", a.authMiddleware, h.UserProfile) + groupV1.Get("/user/customer-profile", a.authMiddleware, h.CustomerProfile) + groupV1.Get("/user/admin-profile", a.authMiddleware, h.AdminProfile) groupV1.Get("/user/single/:id", a.authMiddleware, h.GetUserByID) groupV1.Delete("/user/delete/:id", a.authMiddleware, h.DeleteUser) groupV1.Post("/user/suspend", a.authMiddleware, h.UpdateUserSuspend) @@ -162,7 +164,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/events/:id", h.GetUpcomingEventByID) groupV1.Delete("/events/:id", a.authMiddleware, a.SuperAdminOnly, h.SetEventStatusToRemoved) groupV1.Get("/top-leagues", h.GetTopLeagues) - groupV1.Get("/events/:id/flag", h.UpdateEventFlagged) + groupV1.Put("/events/:id/featured", h.UpdateEventFeatured) // Leagues groupV1.Get("/leagues", h.GetAllLeagues) @@ -180,9 +182,11 @@ func (a *App) initAppRoutes() { groupV1.Put("/branch/:id/set-active", a.authMiddleware, h.UpdateBranchStatus) groupV1.Put("/branch/:id/set-inactive", a.authMiddleware, h.UpdateBranchStatus) groupV1.Delete("/branch/:id", a.authMiddleware, h.DeleteBranch) + groupV1.Get("/search/branch", a.authMiddleware, h.SearchBranch) - // /branch/search - // branch/wallet + + groupV1.Get("/branchLocation", a.authMiddleware, h.GetAllBranchLocations) + groupV1.Get("/branch/:id/cashiers", a.authMiddleware, h.GetBranchCashiers) groupV1.Get("/branchCashier", a.authMiddleware, h.GetBranchForCashier) @@ -212,6 +216,7 @@ func (a *App) initAppRoutes() { // Bet Routes groupV1.Post("/sport/bet", a.authMiddleware, h.CreateBet) groupV1.Post("/sport/bet/fastcode", a.authMiddleware, h.CreateBetWithFastCode) + groupV1.Get("/sport/bet/fastcode/:fast_code", h.GetBetByFastCode) groupV1.Get("/sport/bet", a.authMiddleware, h.GetAllBet) groupV1.Get("/sport/bet/:id", h.GetBetByID) groupV1.Patch("/sport/bet/:id", a.authMiddleware, h.UpdateCashOut) @@ -245,7 +250,7 @@ func (a *App) initAppRoutes() { groupV1.Get("/currencies/convert", h.ConvertCurrency) //Report Routes - groupV1.Get("/reports/dashboard", h.GetDashboardReport) + groupV1.Get("/reports/dashboard", a.authMiddleware, a.OnlyAdminAndAbove, h.GetDashboardReport) groupV1.Get("/report-files/download/:filename", a.authMiddleware, a.OnlyAdminAndAbove, h.DownloadReportFile) groupV1.Get("/report-files/list", a.authMiddleware, a.OnlyAdminAndAbove, h.ListReportFiles) @@ -269,6 +274,7 @@ func (a *App) initAppRoutes() { // Transactions /shop/transactions groupV1.Post("/shop/bet", a.authMiddleware, a.CompanyOnly, h.CreateShopBet) + groupV1.Get("/shop/bet", a.authMiddleware, a.CompanyOnly, h.GetAllShopBets) groupV1.Get("/shop/bet/:id", a.authMiddleware, a.CompanyOnly, h.GetShopBetByBetID) groupV1.Post("/shop/bet/:id/cashout", a.authMiddleware, a.CompanyOnly, h.CashoutBet) groupV1.Post("/shop/bet/:id/generate", a.authMiddleware, a.CompanyOnly, h.CashoutBet)