From e5f42f192801f30a6e8f4d40f170634920a47ded Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Fri, 12 Sep 2025 16:28:39 +0300 Subject: [PATCH] fix: refactor bonus and bonus settings; added welcome bonus --- cmd/main.go | 2 +- db/data/001_initial_seed_data.sql | 8 +- db/migrations/000001_fortune.up.sql | 37 +- db/migrations/000002_notification.up.sql | 3 +- db/query/bonus.sql | 21 +- gen/db/bonus.sql.go | 91 ++-- gen/db/models.go | 3 +- internal/domain/bonus.go | 92 ++-- internal/domain/notification.go | 3 +- internal/domain/setting_list.go | 217 ++++---- internal/pkgs/helpers/helpers.go | 22 +- internal/repository/bonus.go | 19 +- internal/services/bet/notification.go | 247 +++++++++ internal/services/bet/service.go | 239 --------- internal/services/bonus/notification.go | 83 +++ internal/services/bonus/port.go | 4 +- internal/services/bonus/service.go | 99 +++- internal/services/notfication/service.go | 475 ------------------ internal/services/virtualGame/veli/service.go | 28 +- internal/web_server/cron.go | 52 +- internal/web_server/handlers/bonus.go | 120 ++++- internal/web_server/routes.go | 4 +- 22 files changed, 875 insertions(+), 994 deletions(-) create mode 100644 internal/services/bet/notification.go create mode 100644 internal/services/bonus/notification.go delete mode 100644 internal/services/notfication/service.go diff --git a/cmd/main.go b/cmd/main.go index 2a5d805..bfa197b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -142,7 +142,7 @@ func main() { ticketSvc := ticket.NewService(store, eventSvc, *oddsSvc, domain.MongoDBLogger, *settingSvc, notificationSvc) betSvc := bet.NewService(store, eventSvc, *oddsSvc, *walletSvc, *branchSvc, *companySvc, *settingSvc, *userSvc, notificationSvc, logger, domain.MongoDBLogger) resultSvc := result.NewService(store, cfg, logger, domain.MongoDBLogger, *betSvc, *oddsSvc, eventSvc, leagueSvc, notificationSvc, messengerSvc, *userSvc) - bonusSvc := bonus.NewService(store, walletSvc, settingSvc, domain.MongoDBLogger) + bonusSvc := bonus.NewService(store, walletSvc, settingSvc, notificationSvc, domain.MongoDBLogger) referalRepo := repository.NewReferralRepository(store) vitualGameRepo := repository.NewVirtualGameRepository(store) recommendationRepo := repository.NewRecommendationRepository(store) diff --git a/db/data/001_initial_seed_data.sql b/db/data/001_initial_seed_data.sql index 55a3650..479027e 100644 --- a/db/data/001_initial_seed_data.sql +++ b/db/data/001_initial_seed_data.sql @@ -88,8 +88,14 @@ VALUES ('sms_provider', 'afro_message'), ('cashback_percentage', '0.2'), ('default_max_referrals', '15'), ('minimum_bet_amount', '100'), + ('bet_duplicate_limit', '5'), ('send_email_on_bet_finish', 'true'), - ('send_sms_on_bet_finish', 'false') ON CONFLICT (key) DO NOTHING; + ('send_sms_on_bet_finish', 'false'), + ('welcome_bonus_active', 'false'), + ('welcome_bonus_multiplier', '1.5'), + ('welcome_bonus_multiplier', '100000'), + ('welcome_bonus_count', '3'), + ('welcome_bonus_expiry', '10') ON CONFLICT (key) DO NOTHING; -- Users INSERT INTO users ( id, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index c6eefde..966e2b2 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -457,11 +457,12 @@ CREATE TABLE user_bonuses ( id BIGINT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL, + type TEXT NOT NULL, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - bonus_code TEXT NOT NULL UNIQUE, reward_amount BIGINT NOT NULL, is_claimed BOOLEAN NOT NULL DEFAULT false, expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -500,7 +501,23 @@ CREATE TABLE direct_deposits ( CREATE INDEX idx_direct_deposits_status ON direct_deposits (status); CREATE INDEX idx_direct_deposits_customer ON direct_deposits (customer_id); CREATE INDEX idx_direct_deposits_reference ON direct_deposits (bank_reference); --- Views +CREATE TABLE IF NOT EXISTS raffles ( + id SERIAL PRIMARY KEY, + company_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL, + type VARCHAR(50) NOT NULL CHECK (type IN ('virtual', 'sport')), + status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed')) +); +CREATE TABLE IF NOT EXISTS raffle_tickets ( + id SERIAL PRIMARY KEY, + raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE, + user_id INT NOT NULL, + is_active BOOL DEFAULT true, + UNIQUE (raffle_id, user_id) +); +------ Views CREATE VIEW companies_details AS SELECT companies.*, wallets.balance, @@ -526,22 +543,6 @@ CREATE TABLE IF NOT EXISTS supported_operations ( name VARCHAR(255) NOT NULL, description VARCHAR(255) NOT NULL ); -CREATE TABLE IF NOT EXISTS raffles ( - id SERIAL PRIMARY KEY, - company_id INT NOT NULL, - name VARCHAR(255) NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - expires_at TIMESTAMP NOT NULL, - type VARCHAR(50) NOT NULL CHECK (type IN ('virtual', 'sport')), - status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed')) -); -CREATE TABLE IF NOT EXISTS raffle_tickets ( - id SERIAL PRIMARY KEY, - raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE, - user_id INT NOT NULL, - is_active BOOL DEFAULT true, - UNIQUE (raffle_id, user_id) -); CREATE VIEW bet_with_outcomes AS SELECT bets.*, CONCAT (users.first_name, ' ', users.last_name) AS full_name, diff --git a/db/migrations/000002_notification.up.sql b/db/migrations/000002_notification.up.sql index 1845f48..8fd9ad8 100644 --- a/db/migrations/000002_notification.up.sql +++ b/db/migrations/000002_notification.up.sql @@ -18,7 +18,8 @@ CREATE TABLE IF NOT EXISTS notifications ( 'admin_alert', 'bet_result', 'transfer_rejected', - 'approval_required' + 'approval_required', + 'bonus_awarded' ) ), level TEXT NOT NULL CHECK (level IN ('info', 'error', 'warning', 'success')), diff --git a/db/query/bonus.sql b/db/query/bonus.sql index 4b07761..216528a 100644 --- a/db/query/bonus.sql +++ b/db/query/bonus.sql @@ -2,8 +2,8 @@ INSERT INTO user_bonuses ( name, description, + type, user_id, - bonus_code, reward_amount, expires_at ) @@ -11,15 +11,24 @@ VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; -- name: GetAllUserBonuses :many SELECT * -FROM user_bonuses; +FROM user_bonuses +WHERE ( + user_id = sqlc.narg('user_id') + OR sqlc.narg('user_id') IS NULL + ) +LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetUserBonusByID :one SELECT * FROM user_bonuses WHERE id = $1; --- name: GetBonusesByUserID :many -SELECT * + +-- name: GetBonusCount :one +SELECT COUNT(*) FROM user_bonuses -WHERE user_id = $1; +WHERE ( + user_id = sqlc.narg('user_id') + OR sqlc.narg('user_id') IS NULL + ); -- name: GetBonusStats :one SELECT COUNT(*) AS total_bonuses, COALESCE(SUM(reward_amount), 0)::bigint AS total_reward_earned, @@ -30,7 +39,7 @@ SELECT COUNT(*) AS total_bonuses, ) AS claimed_bonuses, COUNT( CASE - WHEN expires_at > now() THEN 1 + WHEN expires_at < now() THEN 1 END ) AS expired_bonuses FROM user_bonuses diff --git a/gen/db/bonus.sql.go b/gen/db/bonus.sql.go index fe0b99b..7c6f168 100644 --- a/gen/db/bonus.sql.go +++ b/gen/db/bonus.sql.go @@ -15,20 +15,20 @@ const CreateUserBonus = `-- name: CreateUserBonus :one INSERT INTO user_bonuses ( name, description, + type, user_id, - bonus_code, reward_amount, expires_at ) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at +RETURNING id, name, description, type, user_id, reward_amount, is_claimed, expires_at, claimed_at, created_at, updated_at ` type CreateUserBonusParams struct { Name string `json:"name"` Description string `json:"description"` + Type string `json:"type"` UserID int64 `json:"user_id"` - BonusCode string `json:"bonus_code"` RewardAmount int64 `json:"reward_amount"` ExpiresAt pgtype.Timestamp `json:"expires_at"` } @@ -37,8 +37,8 @@ func (q *Queries) CreateUserBonus(ctx context.Context, arg CreateUserBonusParams row := q.db.QueryRow(ctx, CreateUserBonus, arg.Name, arg.Description, + arg.Type, arg.UserID, - arg.BonusCode, arg.RewardAmount, arg.ExpiresAt, ) @@ -47,11 +47,12 @@ func (q *Queries) CreateUserBonus(ctx context.Context, arg CreateUserBonusParams &i.ID, &i.Name, &i.Description, + &i.Type, &i.UserID, - &i.BonusCode, &i.RewardAmount, &i.IsClaimed, &i.ExpiresAt, + &i.ClaimedAt, &i.CreatedAt, &i.UpdatedAt, ) @@ -69,12 +70,23 @@ func (q *Queries) DeleteUserBonus(ctx context.Context, id int64) error { } const GetAllUserBonuses = `-- name: GetAllUserBonuses :many -SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at +SELECT id, name, description, type, user_id, reward_amount, is_claimed, expires_at, claimed_at, created_at, updated_at FROM user_bonuses +WHERE ( + user_id = $1 + OR $1 IS NULL + ) +LIMIT $3 OFFSET $2 ` -func (q *Queries) GetAllUserBonuses(ctx context.Context) ([]UserBonuse, error) { - rows, err := q.db.Query(ctx, GetAllUserBonuses) +type GetAllUserBonusesParams struct { + UserID pgtype.Int8 `json:"user_id"` + Offset pgtype.Int4 `json:"offset"` + Limit pgtype.Int4 `json:"limit"` +} + +func (q *Queries) GetAllUserBonuses(ctx context.Context, arg GetAllUserBonusesParams) ([]UserBonuse, error) { + rows, err := q.db.Query(ctx, GetAllUserBonuses, arg.UserID, arg.Offset, arg.Limit) if err != nil { return nil, err } @@ -86,11 +98,12 @@ func (q *Queries) GetAllUserBonuses(ctx context.Context) ([]UserBonuse, error) { &i.ID, &i.Name, &i.Description, + &i.Type, &i.UserID, - &i.BonusCode, &i.RewardAmount, &i.IsClaimed, &i.ExpiresAt, + &i.ClaimedAt, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -104,6 +117,22 @@ func (q *Queries) GetAllUserBonuses(ctx context.Context) ([]UserBonuse, error) { return items, nil } +const GetBonusCount = `-- name: GetBonusCount :one +SELECT COUNT(*) +FROM user_bonuses +WHERE ( + user_id = $1 + OR $1 IS NULL + ) +` + +func (q *Queries) GetBonusCount(ctx context.Context, userID pgtype.Int8) (int64, error) { + row := q.db.QueryRow(ctx, GetBonusCount, userID) + var count int64 + err := row.Scan(&count) + return count, err +} + const GetBonusStats = `-- name: GetBonusStats :one SELECT COUNT(*) AS total_bonuses, COALESCE(SUM(reward_amount), 0)::bigint AS total_reward_earned, @@ -114,7 +143,7 @@ SELECT COUNT(*) AS total_bonuses, ) AS claimed_bonuses, COUNT( CASE - WHEN expires_at > now() THEN 1 + WHEN expires_at < now() THEN 1 END ) AS expired_bonuses FROM user_bonuses @@ -153,45 +182,8 @@ func (q *Queries) GetBonusStats(ctx context.Context, arg GetBonusStatsParams) (G return i, err } -const GetBonusesByUserID = `-- name: GetBonusesByUserID :many -SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at -FROM user_bonuses -WHERE user_id = $1 -` - -func (q *Queries) GetBonusesByUserID(ctx context.Context, userID int64) ([]UserBonuse, error) { - rows, err := q.db.Query(ctx, GetBonusesByUserID, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []UserBonuse - for rows.Next() { - var i UserBonuse - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Description, - &i.UserID, - &i.BonusCode, - &i.RewardAmount, - &i.IsClaimed, - &i.ExpiresAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const GetUserBonusByID = `-- name: GetUserBonusByID :one -SELECT id, name, description, user_id, bonus_code, reward_amount, is_claimed, expires_at, created_at, updated_at +SELECT id, name, description, type, user_id, reward_amount, is_claimed, expires_at, claimed_at, created_at, updated_at FROM user_bonuses WHERE id = $1 ` @@ -203,11 +195,12 @@ func (q *Queries) GetUserBonusByID(ctx context.Context, id int64) (UserBonuse, e &i.ID, &i.Name, &i.Description, + &i.Type, &i.UserID, - &i.BonusCode, &i.RewardAmount, &i.IsClaimed, &i.ExpiresAt, + &i.ClaimedAt, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/gen/db/models.go b/gen/db/models.go index c206de5..b8bbdc9 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -750,11 +750,12 @@ type UserBonuse struct { ID int64 `json:"id"` Name string `json:"name"` Description string `json:"description"` + Type string `json:"type"` UserID int64 `json:"user_id"` - BonusCode string `json:"bonus_code"` RewardAmount int64 `json:"reward_amount"` IsClaimed bool `json:"is_claimed"` ExpiresAt pgtype.Timestamp `json:"expires_at"` + ClaimedAt pgtype.Timestamp `json:"claimed_at"` CreatedAt pgtype.Timestamp `json:"created_at"` UpdatedAt pgtype.Timestamp `json:"updated_at"` } diff --git a/internal/domain/bonus.go b/internal/domain/bonus.go index 94d88a7..f436381 100644 --- a/internal/domain/bonus.go +++ b/internal/domain/bonus.go @@ -7,12 +7,19 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type BonusType string + +var ( + WelcomeBonus BonusType = "welcome_bonus" + DepositBonus BonusType = "deposit_bonus" +) + type UserBonus struct { ID int64 Name string Description string UserID int64 - BonusCode string + Type BonusType RewardAmount Currency IsClaimed bool ExpiresAt time.Time @@ -25,7 +32,7 @@ type UserBonusRes struct { Name string `json:"name"` Description string `json:"description"` UserID int64 `json:"user_id"` - BonusCode string `json:"bonus_code"` + Type BonusType `json:"type"` RewardAmount float32 `json:"reward_amount"` IsClaimed bool `json:"is_claimed"` ExpiresAt time.Time `json:"expires_at"` @@ -38,8 +45,8 @@ func ConvertToBonusRes(bonus UserBonus) UserBonusRes { ID: bonus.ID, Name: bonus.Name, Description: bonus.Description, + Type: bonus.Type, UserID: bonus.UserID, - BonusCode: bonus.BonusCode, RewardAmount: bonus.RewardAmount.Float32(), IsClaimed: bonus.IsClaimed, ExpiresAt: bonus.ExpiresAt, @@ -48,41 +55,51 @@ func ConvertToBonusRes(bonus UserBonus) UserBonusRes { } } +func ConvertToBonusResList(bonuses []UserBonus) []UserBonusRes { + result := make([]UserBonusRes, len(bonuses)) + + for i, bonus := range bonuses { + result[i] = ConvertToBonusRes(bonus) + } + + return result +} + type CreateBonus struct { Name string Description string + Type BonusType UserID int64 - BonusCode string RewardAmount Currency ExpiresAt time.Time } -type CreateBonusReq struct { - Name string `json:"name"` - Description string `json:"description"` - UserID int64 `json:"user_id"` - BonusCode string `json:"bonus_code"` - RewardAmount float32 `json:"reward_amount"` - ExpiresAt time.Time `json:"expires_at"` -} +// type CreateBonusReq struct { +// Name string `json:"name"` +// Description string `json:"description"` +// Type BonusType `json:"type"` +// UserID int64 `json:"user_id"` +// RewardAmount float32 `json:"reward_amount"` +// ExpiresAt time.Time `json:"expires_at"` +// } -func ConvertCreateBonusReq(bonus CreateBonusReq, companyID int64) CreateBonus { - return CreateBonus{ - Name: bonus.Name, - Description: bonus.Description, - UserID: bonus.UserID, - BonusCode: bonus.BonusCode, - RewardAmount: ToCurrency(bonus.RewardAmount), - ExpiresAt: bonus.ExpiresAt, - } -} +// func ConvertCreateBonusReq(bonus CreateBonusReq, companyID int64) CreateBonus { +// return CreateBonus{ +// Name: bonus.Name, +// Description: bonus.Description, +// Type: bonus.Type, +// UserID: bonus.UserID, +// RewardAmount: ToCurrency(bonus.RewardAmount), +// ExpiresAt: bonus.ExpiresAt, +// } +// } func ConvertCreateBonus(bonus CreateBonus) dbgen.CreateUserBonusParams { return dbgen.CreateUserBonusParams{ Name: bonus.Name, Description: bonus.Description, + Type: string(bonus.Type), UserID: bonus.UserID, - BonusCode: bonus.BonusCode, RewardAmount: int64(bonus.RewardAmount), ExpiresAt: pgtype.Timestamp{ Time: bonus.ExpiresAt, @@ -93,11 +110,12 @@ func ConvertCreateBonus(bonus CreateBonus) dbgen.CreateUserBonusParams { func ConvertDBBonus(bonus dbgen.UserBonuse) UserBonus { return UserBonus{ - ID: bonus.ID, - Name: bonus.Name, - Description: bonus.Description, - UserID: bonus.UserID, - BonusCode: bonus.BonusCode, + ID: bonus.ID, + Name: bonus.Name, + Description: bonus.Description, + Type: BonusType(bonus.Type), + UserID: bonus.UserID, + RewardAmount: Currency(bonus.RewardAmount), IsClaimed: bonus.IsClaimed, ExpiresAt: bonus.ExpiresAt.Time, @@ -117,6 +135,8 @@ func ConvertDBBonuses(bonuses []dbgen.UserBonuse) []UserBonus { type BonusFilter struct { UserID ValidInt64 CompanyID ValidInt64 + Limit ValidInt + Offset ValidInt } type BonusStats struct { @@ -126,6 +146,22 @@ type BonusStats struct { ExpiredBonuses int64 } +type BonusStatsRes struct { + TotalBonus int64 `json:"total_bonus"` + TotalRewardAmount float32 `json:"total_reward_amount"` + ClaimedBonuses int64 `json:"claimed_bonuses"` + ExpiredBonuses int64 `json:"expired_bonuses"` +} + +func ConvertToBonusStatsRes(bonus BonusStats) BonusStatsRes { + return BonusStatsRes{ + TotalBonus: bonus.TotalBonus, + TotalRewardAmount: bonus.TotalRewardAmount.Float32(), + ClaimedBonuses: bonus.ClaimedBonuses, + ExpiredBonuses: bonus.ExpiredBonuses, + } +} + func ConvertDBBonusStats(stats dbgen.GetBonusStatsRow) BonusStats { return BonusStats{ TotalBonus: stats.TotalBonuses, diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 28dbed2..97fc9d1 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -32,6 +32,7 @@ const ( NOTIFICATION_TYPE_BET_RESULT NotificationType = "bet_result" NOTIFICATION_TYPE_TRANSFER_REJECTED NotificationType = "transfer_rejected" NOTIFICATION_TYPE_APPROVAL_REQUIRED NotificationType = "approval_required" + NOTIFICATION_TYPE_BONUS_AWARDED NotificationType = "bonus_awarded" NotificationRecieverSideAdmin NotificationRecieverSide = "admin" NotificationRecieverSideCustomer NotificationRecieverSide = "customer" @@ -73,7 +74,7 @@ type Notification struct { RecipientID int64 `json:"recipient_id"` Type NotificationType `json:"type"` Level NotificationLevel `json:"level"` - ErrorSeverity NotificationErrorSeverity `json:"error_severity"` + ErrorSeverity NotificationErrorSeverity `json:"error_severity"` Reciever NotificationRecieverSide `json:"reciever"` IsRead bool `json:"is_read"` DeliveryStatus NotificationDeliveryStatus `json:"delivery_status,omitempty"` diff --git a/internal/domain/setting_list.go b/internal/domain/setting_list.go index 3b376ec..5a5c86e 100644 --- a/internal/domain/setting_list.go +++ b/internal/domain/setting_list.go @@ -39,117 +39,147 @@ type SettingList struct { } type SettingListRes struct { - SMSProvider SMSProvider `json:"sms_provider"` - MaxNumberOfOutcomes int64 `json:"max_number_of_outcomes"` - BetAmountLimit float32 `json:"bet_amount_limit"` - DailyTicketPerIP int64 `json:"daily_ticket_limit"` - TotalWinningLimit float32 `json:"total_winning_limit"` - AmountForBetReferral float32 `json:"amount_for_bet_referral"` - CashbackAmountCap float32 `json:"cashback_amount_cap"` - DefaultWinningLimit int64 `json:"default_winning_limit"` - ReferralRewardAmount float32 `json:"referral_reward_amount"` - CashbackPercentage float32 `json:"cashback_percentage"` - DefaultMaxReferrals int64 `json:"default_max_referrals"` - MinimumBetAmount float32 `json:"minimum_bet_amount"` - BetDuplicateLimit int64 `json:"bet_duplicate_limit"` - SendEmailOnBetFinish bool `json:"send_email_on_bet_finish"` - SendSMSOnBetFinish bool `json:"send_sms_on_bet_finish"` + SMSProvider SMSProvider `json:"sms_provider"` + MaxNumberOfOutcomes int64 `json:"max_number_of_outcomes"` + BetAmountLimit float32 `json:"bet_amount_limit"` + DailyTicketPerIP int64 `json:"daily_ticket_limit"` + TotalWinningLimit float32 `json:"total_winning_limit"` + AmountForBetReferral float32 `json:"amount_for_bet_referral"` + CashbackAmountCap float32 `json:"cashback_amount_cap"` + DefaultWinningLimit int64 `json:"default_winning_limit"` + ReferralRewardAmount float32 `json:"referral_reward_amount"` + CashbackPercentage float32 `json:"cashback_percentage"` + DefaultMaxReferrals int64 `json:"default_max_referrals"` + MinimumBetAmount float32 `json:"minimum_bet_amount"` + BetDuplicateLimit int64 `json:"bet_duplicate_limit"` + SendEmailOnBetFinish bool `json:"send_email_on_bet_finish"` + SendSMSOnBetFinish bool `json:"send_sms_on_bet_finish"` + WelcomeBonusActive bool `json:"welcome_bonus_active"` + WelcomeBonusMultiplier float32 `json:"welcome_bonus_multiplier"` + WelcomeBonusCap float32 `json:"welcome_bonus_cap"` + WelcomeBonusCount int64 `json:"welcome_bonus_count"` + WelcomeBonusExpire int64 `json:"welcome_bonus_expiry"` } func ConvertSettingListRes(settings SettingList) SettingListRes { return SettingListRes{ - SMSProvider: settings.SMSProvider, - MaxNumberOfOutcomes: settings.MaxNumberOfOutcomes, - BetAmountLimit: settings.BetAmountLimit.Float32(), - DailyTicketPerIP: settings.DailyTicketPerIP, - TotalWinningLimit: settings.TotalWinningLimit.Float32(), - AmountForBetReferral: settings.AmountForBetReferral.Float32(), - CashbackAmountCap: settings.CashbackAmountCap.Float32(), - DefaultWinningLimit: settings.DefaultWinningLimit, - ReferralRewardAmount: settings.ReferralRewardAmount.Float32(), - CashbackPercentage: settings.CashbackPercentage, - DefaultMaxReferrals: settings.DefaultMaxReferrals, - MinimumBetAmount: settings.MinimumBetAmount.Float32(), - BetDuplicateLimit: settings.BetDuplicateLimit, - SendEmailOnBetFinish: settings.SendEmailOnBetFinish, - SendSMSOnBetFinish: settings.SendSMSOnBetFinish, + SMSProvider: settings.SMSProvider, + MaxNumberOfOutcomes: settings.MaxNumberOfOutcomes, + BetAmountLimit: settings.BetAmountLimit.Float32(), + DailyTicketPerIP: settings.DailyTicketPerIP, + TotalWinningLimit: settings.TotalWinningLimit.Float32(), + AmountForBetReferral: settings.AmountForBetReferral.Float32(), + CashbackAmountCap: settings.CashbackAmountCap.Float32(), + DefaultWinningLimit: settings.DefaultWinningLimit, + ReferralRewardAmount: settings.ReferralRewardAmount.Float32(), + CashbackPercentage: settings.CashbackPercentage, + DefaultMaxReferrals: settings.DefaultMaxReferrals, + MinimumBetAmount: settings.MinimumBetAmount.Float32(), + BetDuplicateLimit: settings.BetDuplicateLimit, + SendEmailOnBetFinish: settings.SendEmailOnBetFinish, + SendSMSOnBetFinish: settings.SendSMSOnBetFinish, + WelcomeBonusActive: settings.WelcomeBonusActive, + WelcomeBonusMultiplier: settings.WelcomeBonusMultiplier, + WelcomeBonusCap: settings.WelcomeBonusCap.Float32(), + WelcomeBonusCount: settings.WelcomeBonusCount, + WelcomeBonusExpire: settings.WelcomeBonusExpire, } } type SaveSettingListReq struct { - SMSProvider *string `json:"sms_provider,omitempty"` - MaxNumberOfOutcomes *int64 `json:"max_number_of_outcomes,omitempty"` - BetAmountLimit *float32 `json:"bet_amount_limit,omitempty"` - DailyTicketPerIP *int64 `json:"daily_ticket_limit,omitempty"` - TotalWinningLimit *float32 `json:"total_winning_limit,omitempty"` - AmountForBetReferral *float32 `json:"amount_for_bet_referral,omitempty"` - CashbackAmountCap *float32 `json:"cashback_amount_cap,omitempty"` - DefaultWinningLimit *int64 `json:"default_winning_limit,omitempty"` - ReferralRewardAmount *float32 `json:"referral_reward_amount"` - CashbackPercentage *float32 `json:"cashback_percentage"` - DefaultMaxReferrals *int64 `json:"default_max_referrals"` - MinimumBetAmount *float32 `json:"minimum_bet_amount"` - BetDuplicateLimit *int64 `json:"bet_duplicate_limit"` - SendEmailOnBetFinish *bool `json:"send_email_on_bet_finish"` - SendSMSOnBetFinish *bool `json:"send_sms_on_bet_finish"` + SMSProvider *string `json:"sms_provider,omitempty"` + MaxNumberOfOutcomes *int64 `json:"max_number_of_outcomes,omitempty"` + BetAmountLimit *float32 `json:"bet_amount_limit,omitempty"` + DailyTicketPerIP *int64 `json:"daily_ticket_limit,omitempty"` + TotalWinningLimit *float32 `json:"total_winning_limit,omitempty"` + AmountForBetReferral *float32 `json:"amount_for_bet_referral,omitempty"` + CashbackAmountCap *float32 `json:"cashback_amount_cap,omitempty"` + DefaultWinningLimit *int64 `json:"default_winning_limit,omitempty"` + ReferralRewardAmount *float32 `json:"referral_reward_amount"` + CashbackPercentage *float32 `json:"cashback_percentage"` + DefaultMaxReferrals *int64 `json:"default_max_referrals"` + MinimumBetAmount *float32 `json:"minimum_bet_amount"` + BetDuplicateLimit *int64 `json:"bet_duplicate_limit"` + SendEmailOnBetFinish *bool `json:"send_email_on_bet_finish"` + SendSMSOnBetFinish *bool `json:"send_sms_on_bet_finish"` + WelcomeBonusActive *bool `json:"welcome_bonus_active"` + WelcomeBonusMultiplier *float32 `json:"welcome_bonus_multiplier"` + WelcomeBonusCap *float32 `json:"welcome_bonus_cap"` + WelcomeBonusCount *int64 `json:"welcome_bonus_count"` + WelcomeBonusExpire *int64 `json:"welcome_bonus_expiry"` } type ValidSettingList struct { - SMSProvider ValidString - MaxNumberOfOutcomes ValidInt64 - BetAmountLimit ValidCurrency - DailyTicketPerIP ValidInt64 - TotalWinningLimit ValidCurrency - AmountForBetReferral ValidCurrency - CashbackAmountCap ValidCurrency - DefaultWinningLimit ValidInt64 - ReferralRewardAmount ValidCurrency - CashbackPercentage ValidFloat32 - DefaultMaxReferrals ValidInt64 - MinimumBetAmount ValidCurrency - BetDuplicateLimit ValidInt64 - SendEmailOnBetFinish ValidBool - SendSMSOnBetFinish ValidBool + SMSProvider ValidString + MaxNumberOfOutcomes ValidInt64 + BetAmountLimit ValidCurrency + DailyTicketPerIP ValidInt64 + TotalWinningLimit ValidCurrency + AmountForBetReferral ValidCurrency + CashbackAmountCap ValidCurrency + DefaultWinningLimit ValidInt64 + ReferralRewardAmount ValidCurrency + CashbackPercentage ValidFloat32 + DefaultMaxReferrals ValidInt64 + MinimumBetAmount ValidCurrency + BetDuplicateLimit ValidInt64 + SendEmailOnBetFinish ValidBool + SendSMSOnBetFinish ValidBool + WelcomeBonusActive ValidBool + WelcomeBonusMultiplier ValidFloat32 + WelcomeBonusCap ValidCurrency + WelcomeBonusCount ValidInt64 + WelcomeBonusExpire ValidInt64 } func ConvertSaveSettingListReq(settings SaveSettingListReq) ValidSettingList { return ValidSettingList{ - SMSProvider: ConvertStringPtr(settings.SMSProvider), - MaxNumberOfOutcomes: ConvertInt64Ptr(settings.MaxNumberOfOutcomes), - BetAmountLimit: ConvertFloat32PtrToCurrency(settings.BetAmountLimit), - DailyTicketPerIP: ConvertInt64Ptr(settings.DailyTicketPerIP), - TotalWinningLimit: ConvertFloat32PtrToCurrency(settings.TotalWinningLimit), - AmountForBetReferral: ConvertFloat32PtrToCurrency(settings.AmountForBetReferral), - CashbackAmountCap: ConvertFloat32PtrToCurrency(settings.CashbackAmountCap), - DefaultWinningLimit: ConvertInt64Ptr(settings.DefaultWinningLimit), - ReferralRewardAmount: ConvertFloat32PtrToCurrency(settings.ReferralRewardAmount), - CashbackPercentage: ConvertFloat32Ptr(settings.CashbackPercentage), - DefaultMaxReferrals: ConvertInt64Ptr(settings.DefaultMaxReferrals), - MinimumBetAmount: ConvertFloat32PtrToCurrency(settings.MinimumBetAmount), - BetDuplicateLimit: ConvertInt64Ptr(settings.BetDuplicateLimit), - SendEmailOnBetFinish: ConvertBoolPtr(settings.SendEmailOnBetFinish), - SendSMSOnBetFinish: ConvertBoolPtr(settings.SendSMSOnBetFinish), + SMSProvider: ConvertStringPtr(settings.SMSProvider), + MaxNumberOfOutcomes: ConvertInt64Ptr(settings.MaxNumberOfOutcomes), + BetAmountLimit: ConvertFloat32PtrToCurrency(settings.BetAmountLimit), + DailyTicketPerIP: ConvertInt64Ptr(settings.DailyTicketPerIP), + TotalWinningLimit: ConvertFloat32PtrToCurrency(settings.TotalWinningLimit), + AmountForBetReferral: ConvertFloat32PtrToCurrency(settings.AmountForBetReferral), + CashbackAmountCap: ConvertFloat32PtrToCurrency(settings.CashbackAmountCap), + DefaultWinningLimit: ConvertInt64Ptr(settings.DefaultWinningLimit), + ReferralRewardAmount: ConvertFloat32PtrToCurrency(settings.ReferralRewardAmount), + CashbackPercentage: ConvertFloat32Ptr(settings.CashbackPercentage), + DefaultMaxReferrals: ConvertInt64Ptr(settings.DefaultMaxReferrals), + MinimumBetAmount: ConvertFloat32PtrToCurrency(settings.MinimumBetAmount), + BetDuplicateLimit: ConvertInt64Ptr(settings.BetDuplicateLimit), + SendEmailOnBetFinish: ConvertBoolPtr(settings.SendEmailOnBetFinish), + SendSMSOnBetFinish: ConvertBoolPtr(settings.SendSMSOnBetFinish), + WelcomeBonusActive: ConvertBoolPtr(settings.WelcomeBonusActive), + WelcomeBonusMultiplier: ConvertFloat32Ptr(settings.WelcomeBonusMultiplier), + WelcomeBonusCap: ConvertFloat32PtrToCurrency(settings.WelcomeBonusCap), + WelcomeBonusCount: ConvertInt64Ptr(settings.WelcomeBonusCount), + WelcomeBonusExpire: ConvertInt64Ptr(settings.WelcomeBonusExpire), } } // Always make sure to run the validation before converting this func (vsl *ValidSettingList) ToSettingList() SettingList { return SettingList{ - SMSProvider: SMSProvider(vsl.SMSProvider.Value), - MaxNumberOfOutcomes: vsl.MaxNumberOfOutcomes.Value, - BetAmountLimit: vsl.BetAmountLimit.Value, - DailyTicketPerIP: vsl.DailyTicketPerIP.Value, - TotalWinningLimit: vsl.TotalWinningLimit.Value, - AmountForBetReferral: vsl.AmountForBetReferral.Value, - CashbackAmountCap: vsl.CashbackAmountCap.Value, - DefaultWinningLimit: vsl.DefaultWinningLimit.Value, - ReferralRewardAmount: vsl.ReferralRewardAmount.Value, - CashbackPercentage: vsl.CashbackPercentage.Value, - DefaultMaxReferrals: vsl.DefaultMaxReferrals.Value, - MinimumBetAmount: vsl.MinimumBetAmount.Value, - BetDuplicateLimit: vsl.BetDuplicateLimit.Value, - SendEmailOnBetFinish: vsl.SendEmailOnBetFinish.Value, - SendSMSOnBetFinish: vsl.SendSMSOnBetFinish.Value, + SMSProvider: SMSProvider(vsl.SMSProvider.Value), + MaxNumberOfOutcomes: vsl.MaxNumberOfOutcomes.Value, + BetAmountLimit: vsl.BetAmountLimit.Value, + DailyTicketPerIP: vsl.DailyTicketPerIP.Value, + TotalWinningLimit: vsl.TotalWinningLimit.Value, + AmountForBetReferral: vsl.AmountForBetReferral.Value, + CashbackAmountCap: vsl.CashbackAmountCap.Value, + DefaultWinningLimit: vsl.DefaultWinningLimit.Value, + ReferralRewardAmount: vsl.ReferralRewardAmount.Value, + CashbackPercentage: vsl.CashbackPercentage.Value, + DefaultMaxReferrals: vsl.DefaultMaxReferrals.Value, + MinimumBetAmount: vsl.MinimumBetAmount.Value, + BetDuplicateLimit: vsl.BetDuplicateLimit.Value, + SendEmailOnBetFinish: vsl.SendEmailOnBetFinish.Value, + SendSMSOnBetFinish: vsl.SendSMSOnBetFinish.Value, + WelcomeBonusActive: vsl.WelcomeBonusActive.Value, + WelcomeBonusMultiplier: vsl.WelcomeBonusMultiplier.Value, + WelcomeBonusCap: vsl.WelcomeBonusCap.Value, + WelcomeBonusCount: vsl.WelcomeBonusCount.Value, + WelcomeBonusExpire: vsl.WelcomeBonusExpire.Value, } } @@ -168,6 +198,8 @@ func (vsl *ValidSettingList) GetInt64SettingsMap() map[string]*ValidInt64 { "default_winning_limit": &vsl.DefaultWinningLimit, "default_max_referrals": &vsl.DefaultMaxReferrals, "bet_duplicate_limit": &vsl.BetDuplicateLimit, + "welcome_bonus_count": &vsl.WelcomeBonusCount, + "welcome_bonus_expiry": &vsl.WelcomeBonusExpire, } } @@ -179,6 +211,7 @@ func (vsl *ValidSettingList) GetCurrencySettingsMap() map[string]*ValidCurrency "cashback_amount_cap": &vsl.CashbackAmountCap, "referral_reward_amount": &vsl.ReferralRewardAmount, "minimum_bet_amount": &vsl.MinimumBetAmount, + "welcome_bonus_cap": &vsl.WelcomeBonusCap, } } @@ -192,12 +225,14 @@ func (vsl *ValidSettingList) GetBoolSettingsMap() map[string]*ValidBool { return map[string]*ValidBool{ "send_email_on_bet_finish": &vsl.SendEmailOnBetFinish, "send_sms_on_bet_finish": &vsl.SendSMSOnBetFinish, + "welcome_bonus_active": &vsl.WelcomeBonusActive, } } func (vsl *ValidSettingList) GetFloat32SettingsMap() map[string]*ValidFloat32 { return map[string]*ValidFloat32{ - "cashback_percentage": &vsl.CashbackPercentage, + "cashback_percentage": &vsl.CashbackPercentage, + "welcome_bonus_multiplier": &vsl.WelcomeBonusMultiplier, } } diff --git a/internal/pkgs/helpers/helpers.go b/internal/pkgs/helpers/helpers.go index d9be84a..f336b1b 100644 --- a/internal/pkgs/helpers/helpers.go +++ b/internal/pkgs/helpers/helpers.go @@ -1,10 +1,11 @@ package helpers import ( + random "crypto/rand" "fmt" - "math/rand/v2" - "github.com/google/uuid" + "math/big" + "math/rand/v2" ) func GenerateID() string { @@ -24,3 +25,20 @@ func GenerateFastCode() string { } return code } + +func GenerateCashoutID() (string, error) { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + const length int = 13 + charLen := big.NewInt(int64(len(chars))) + result := make([]byte, length) + + for i := 0; i < length; i++ { + index, err := random.Int(random.Reader, charLen) + if err != nil { + return "", err + } + result[i] = chars[index.Int64()] + } + + return string(result), nil +} diff --git a/internal/repository/bonus.go b/internal/repository/bonus.go index 8f16e04..6b95b3e 100644 --- a/internal/repository/bonus.go +++ b/internal/repository/bonus.go @@ -17,8 +17,12 @@ func (s *Store) CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) ( return domain.ConvertDBBonus(newBonus), nil } -func (s *Store) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error) { - bonuses, err := s.queries.GetAllUserBonuses(ctx) +func (s *Store) GetAllUserBonuses(ctx context.Context, filter domain.BonusFilter) ([]domain.UserBonus, error) { + bonuses, err := s.queries.GetAllUserBonuses(ctx, dbgen.GetAllUserBonusesParams{ + UserID: filter.UserID.ToPG(), + Offset: filter.Offset.ToPG(), + Limit: filter.Limit.ToPG(), + }) if err != nil { return nil, err @@ -27,13 +31,12 @@ func (s *Store) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, erro return domain.ConvertDBBonuses(bonuses), nil } -func (s *Store) GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error) { - bonuses, err := s.queries.GetBonusesByUserID(ctx, userID) +func (s *Store) GetBonusCount(ctx context.Context, filter domain.BonusFilter) (int64, error) { + count, err := s.queries.GetBonusCount(ctx, filter.UserID.ToPG()) if err != nil { - return nil, err + return 0, err } - - return domain.ConvertDBBonuses(bonuses), nil + return count, nil } func (s *Store) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error) { @@ -45,6 +48,8 @@ func (s *Store) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBon } + + func (s *Store) GetBonusStats(ctx context.Context, filter domain.BonusFilter) (domain.BonusStats, error) { bonus, err := s.queries.GetBonusStats(ctx, dbgen.GetBonusStatsParams{ CompanyID: filter.CompanyID.ToPG(), diff --git a/internal/services/bet/notification.go b/internal/services/bet/notification.go new file mode 100644 index 0000000..2d4de4e --- /dev/null +++ b/internal/services/bet/notification.go @@ -0,0 +1,247 @@ +package bet + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "go.uber.org/zap" +) + +func newBetResultNotification(userID int64, level domain.NotificationLevel, channel domain.DeliveryChannel, headline, message string, metadata any) *domain.Notification { + raw, _ := json.Marshal(metadata) + return &domain.Notification{ + RecipientID: userID, + DeliveryStatus: domain.DeliveryStatusPending, + IsRead: false, + Type: domain.NOTIFICATION_TYPE_BET_RESULT, + Level: level, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: channel, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 2, + Metadata: raw, + } +} + +type SendResultNotificationParam struct { + BetID int64 + Status domain.OutcomeStatus + UserID int64 + WinningAmount domain.Currency + Extra string + SendEmail bool + SendSMS bool +} + +func (p SendResultNotificationParam) Validate() error { + if p.BetID == 0 { + return errors.New("BetID is required") + } + if p.UserID == 0 { + return errors.New("UserID is required") + } + return nil +} + +func shouldSend(channel domain.DeliveryChannel, sendEmail, sendSMS bool) bool { + switch { + case channel == domain.DeliveryChannelEmail && sendEmail: + return true + case channel == domain.DeliveryChannelSMS && sendSMS: + return true + case channel == domain.DeliveryChannelInApp: + return true + default: + return false + } +} + +func (s *Service) SendWinningStatusNotification(ctx context.Context, param SendResultNotificationParam) error { + if err := param.Validate(); err != nil { + return err + } + + var headline string + var message string + + switch param.Status { + case domain.OUTCOME_STATUS_WIN: + headline = fmt.Sprintf("Bet #%v Won!", param.BetID) + message = fmt.Sprintf( + "Congratulations! Your bet #%v has won. %.2f has been credited to your wallet.", + param.BetID, + param.WinningAmount.Float32(), + ) + case domain.OUTCOME_STATUS_HALF: + headline = fmt.Sprintf("Bet #%v Half-Win", param.BetID) + message = fmt.Sprintf( + "Your bet #%v resulted in a half-win. %.2f has been credited to your wallet.", + param.BetID, + param.WinningAmount.Float32(), + ) + case domain.OUTCOME_STATUS_VOID: + headline = fmt.Sprintf("Bet #%v Refunded", param.BetID) + message = fmt.Sprintf( + "Your bet #%v has been voided. %.2f has been refunded to your wallet.", + param.BetID, + param.WinningAmount.Float32(), + ) + + default: + return fmt.Errorf("unsupported status: %v", param.Status) + } + + for _, channel := range []domain.DeliveryChannel{ + domain.DeliveryChannelInApp, + domain.DeliveryChannelEmail, + domain.DeliveryChannelSMS, + } { + if !shouldSend(channel, param.SendEmail, param.SendSMS) { + continue + } + n := newBetResultNotification(param.UserID, domain.NotificationLevelSuccess, channel, headline, message, map[string]any{ + "winning_amount": param.WinningAmount.Float32(), + "status": param.Status, + "more": param.Extra, + }) + if err := s.notificationSvc.SendNotification(ctx, n); err != nil { + return err + } + } + + return nil +} + +func (s *Service) SendLosingStatusNotification(ctx context.Context, param SendResultNotificationParam) error { + if err := param.Validate(); err != nil { + return err + } + + var headline string + var message string + + switch param.Status { + case domain.OUTCOME_STATUS_LOSS: + headline = fmt.Sprintf("Bet #%v Lost", param.BetID) + message = "Unfortunately, your bet did not win this time. Better luck next time!" + default: + return fmt.Errorf("unsupported status: %v", param.Status) + } + + for _, channel := range []domain.DeliveryChannel{ + domain.DeliveryChannelInApp, + domain.DeliveryChannelEmail, + domain.DeliveryChannelSMS, + } { + if !shouldSend(channel, param.SendEmail, param.SendSMS) { + continue + } + n := newBetResultNotification(param.UserID, domain.NotificationLevelWarning, channel, headline, message, map[string]any{ + "status": param.Status, + "more": param.Extra, + }) + if err := s.notificationSvc.SendNotification(ctx, n); err != nil { + return err + } + } + + return nil +} + +func (s *Service) SendErrorStatusNotification(ctx context.Context, betID int64, 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 = fmt.Sprintf("Bet #%v Processing Issue", betID) + message = "We encountered a problem while processing your bet. Our team is working to resolve it as soon as possible." + + default: + return fmt.Errorf("unsupported status: %v", status) + } + + for _, channel := range []domain.DeliveryChannel{ + domain.DeliveryChannelInApp, + domain.DeliveryChannelEmail, + } { + n := newBetResultNotification(userID, domain.NotificationLevelError, channel, headline, message, map[string]any{ + "status": status, + "more": extra, + }) + if err := s.notificationSvc.SendNotification(ctx, n); err != nil { + return err + } + } + return nil +} + +func (s *Service) SendAdminAlertNotification(ctx context.Context, betID int64, status domain.OutcomeStatus, extra string, companyID int64) error { + + var headline string + var message string + + switch status { + case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: + headline = fmt.Sprintf("Processing Error for Bet #%v", betID) + message = "A processing error occurred with this bet. Please review and take corrective action." + + default: + return fmt.Errorf("unsupported status: %v", status) + } + + super_admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ + Role: string(domain.RoleSuperAdmin), + }) + + if err != nil { + s.mongoLogger.Error("failed to get super_admin recipients", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ + Role: string(domain.RoleAdmin), + CompanyID: domain.ValidInt64{ + Value: companyID, + Valid: true, + }, + }) + + if err != nil { + s.mongoLogger.Error("failed to get admin recipients", + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return err + } + + users := append(super_admin_users, admin_users...) + + for _, user := range users { + for _, channel := range []domain.DeliveryChannel{ + domain.DeliveryChannelInApp, + domain.DeliveryChannelEmail, + } { + n := newBetResultNotification(user.ID, domain.NotificationLevelError, channel, headline, message, map[string]any{ + "status": status, + "more": extra, + }) + if err := s.notificationSvc.SendNotification(ctx, n); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 65e361f..4ba3a66 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -97,10 +97,6 @@ func (s *Service) GenerateCashoutID() (string, error) { for i := 0; i < length; i++ { index, err := rand.Int(rand.Reader, charLen) if err != nil { - s.mongoLogger.Error("failed to generate random index for cashout ID", - zap.Int("position", i), - zap.Error(err), - ) return "", err } result[i] = chars[index.Int64()] @@ -957,241 +953,6 @@ func (s *Service) UpdateStatus(ctx context.Context, betId int64, status domain.O return nil } -func newBetResultNotification(userID int64, level domain.NotificationLevel, channel domain.DeliveryChannel, headline, message string, metadata any) *domain.Notification { - raw, _ := json.Marshal(metadata) - return &domain.Notification{ - RecipientID: userID, - DeliveryStatus: domain.DeliveryStatusPending, - IsRead: false, - Type: domain.NOTIFICATION_TYPE_BET_RESULT, - Level: level, - Reciever: domain.NotificationRecieverSideCustomer, - DeliveryChannel: channel, - Payload: domain.NotificationPayload{ - Headline: headline, - Message: message, - }, - Priority: 2, - Metadata: raw, - } -} - -type SendResultNotificationParam struct { - BetID int64 - Status domain.OutcomeStatus - UserID int64 - WinningAmount domain.Currency - Extra string - SendEmail bool - SendSMS bool -} - -func (p SendResultNotificationParam) Validate() error { - if p.BetID == 0 { - return errors.New("BetID is required") - } - if p.UserID == 0 { - return errors.New("UserID is required") - } - return nil -} - -func shouldSend(channel domain.DeliveryChannel, sendEmail, sendSMS bool) bool { - switch { - case channel == domain.DeliveryChannelEmail && sendEmail: - return true - case channel == domain.DeliveryChannelSMS && sendSMS: - return true - case channel == domain.DeliveryChannelInApp: - return true - default: - return false - } -} - -func (s *Service) SendWinningStatusNotification(ctx context.Context, param SendResultNotificationParam) error { - if err := param.Validate(); err != nil { - return err - } - - var headline string - var message string - - switch param.Status { - case domain.OUTCOME_STATUS_WIN: - headline = fmt.Sprintf("Bet #%v Won!", param.BetID) - message = fmt.Sprintf( - "Congratulations! Your bet #%v has won. %.2f has been credited to your wallet.", - param.BetID, - param.WinningAmount.Float32(), - ) - case domain.OUTCOME_STATUS_HALF: - headline = fmt.Sprintf("Bet #%v Half-Win", param.BetID) - message = fmt.Sprintf( - "Your bet #%v resulted in a half-win. %.2f has been credited to your wallet.", - param.BetID, - param.WinningAmount.Float32(), - ) - case domain.OUTCOME_STATUS_VOID: - headline = fmt.Sprintf("Bet #%v Refunded", param.BetID) - message = fmt.Sprintf( - "Your bet #%v has been voided. %.2f has been refunded to your wallet.", - param.BetID, - param.WinningAmount.Float32(), - ) - - default: - return fmt.Errorf("unsupported status: %v", param.Status) - } - - for _, channel := range []domain.DeliveryChannel{ - domain.DeliveryChannelInApp, - domain.DeliveryChannelEmail, - domain.DeliveryChannelSMS, - } { - if !shouldSend(channel, param.SendEmail, param.SendSMS) { - continue - } - n := newBetResultNotification(param.UserID, domain.NotificationLevelSuccess, channel, headline, message, map[string]any{ - "winning_amount": param.WinningAmount.Float32(), - "status": param.Status, - "more": param.Extra, - }) - if err := s.notificationSvc.SendNotification(ctx, n); err != nil { - return err - } - } - - return nil -} - -func (s *Service) SendLosingStatusNotification(ctx context.Context, param SendResultNotificationParam) error { - if err := param.Validate(); err != nil { - return err - } - - var headline string - var message string - - switch param.Status { - case domain.OUTCOME_STATUS_LOSS: - headline = fmt.Sprintf("Bet #%v Lost", param.BetID) - message = "Unfortunately, your bet did not win this time. Better luck next time!" - default: - return fmt.Errorf("unsupported status: %v", param.Status) - } - - for _, channel := range []domain.DeliveryChannel{ - domain.DeliveryChannelInApp, - domain.DeliveryChannelEmail, - domain.DeliveryChannelSMS, - } { - if !shouldSend(channel, param.SendEmail, param.SendSMS) { - continue - } - n := newBetResultNotification(param.UserID, domain.NotificationLevelWarning, channel, headline, message, map[string]any{ - "status": param.Status, - "more": param.Extra, - }) - if err := s.notificationSvc.SendNotification(ctx, n); err != nil { - return err - } - } - - return nil -} - -func (s *Service) SendErrorStatusNotification(ctx context.Context, betID int64, 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 = fmt.Sprintf("Bet #%v Processing Issue", betID) - message = "We encountered a problem while processing your bet. Our team is working to resolve it as soon as possible." - - default: - return fmt.Errorf("unsupported status: %v", status) - } - - for _, channel := range []domain.DeliveryChannel{ - domain.DeliveryChannelInApp, - domain.DeliveryChannelEmail, - } { - n := newBetResultNotification(userID, domain.NotificationLevelError, channel, headline, message, map[string]any{ - "status": status, - "more": extra, - }) - if err := s.notificationSvc.SendNotification(ctx, n); err != nil { - return err - } - } - return nil -} - -func (s *Service) SendAdminAlertNotification(ctx context.Context, betID int64, status domain.OutcomeStatus, extra string, companyID int64) error { - - var headline string - var message string - - switch status { - case domain.OUTCOME_STATUS_ERROR, domain.OUTCOME_STATUS_PENDING: - headline = fmt.Sprintf("Processing Error for Bet #%v", betID) - message = "A processing error occurred with this bet. Please review and take corrective action." - - default: - return fmt.Errorf("unsupported status: %v", status) - } - - super_admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ - Role: string(domain.RoleSuperAdmin), - }) - - if err != nil { - s.mongoLogger.Error("failed to get super_admin recipients", - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return err - } - - admin_users, _, err := s.userSvc.GetAllUsers(ctx, domain.UserFilter{ - Role: string(domain.RoleAdmin), - CompanyID: domain.ValidInt64{ - Value: companyID, - Valid: true, - }, - }) - - if err != nil { - s.mongoLogger.Error("failed to get admin recipients", - zap.Error(err), - zap.Time("timestamp", time.Now()), - ) - return err - } - - users := append(super_admin_users, admin_users...) - - for _, user := range users { - for _, channel := range []domain.DeliveryChannel{ - domain.DeliveryChannelInApp, - domain.DeliveryChannelEmail, - } { - n := newBetResultNotification(user.ID, domain.NotificationLevelError, channel, headline, message, map[string]any{ - "status": status, - "more": extra, - }) - if err := s.notificationSvc.SendNotification(ctx, n); err != nil { - 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 { diff --git a/internal/services/bonus/notification.go b/internal/services/bonus/notification.go new file mode 100644 index 0000000..8497a53 --- /dev/null +++ b/internal/services/bonus/notification.go @@ -0,0 +1,83 @@ +package bonus + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) + +type SendBonusNotificationParam struct { + BonusID int64 + UserID int64 + Type domain.BonusType + Amount domain.Currency + SendEmail bool + SendSMS bool +} + +func shouldSend(channel domain.DeliveryChannel, sendEmail, sendSMS bool) bool { + switch { + case channel == domain.DeliveryChannelEmail && sendEmail: + return true + case channel == domain.DeliveryChannelSMS && sendSMS: + return true + case channel == domain.DeliveryChannelInApp: + return true + default: + return false + } +} + +func (s *Service) SendBonusNotification(ctx context.Context, param SendBonusNotificationParam) error { + + var headline string + var message string + + switch param.Type { + case domain.WelcomeBonus: + headline = "You've been awarded a welcome bonus!" + message = fmt.Sprintf( + "Congratulations! A you've been given %.2f as a welcome bonus for you to bet on.", + param.Amount, + ) + default: + return fmt.Errorf("unsupported bonus type: %v", param.Type) + } + for _, channel := range []domain.DeliveryChannel{ + domain.DeliveryChannelInApp, + domain.DeliveryChannelEmail, + domain.DeliveryChannelSMS, + } { + if !shouldSend(channel, param.SendEmail, param.SendSMS) { + continue + } + + raw, _ := json.Marshal(map[string]any{ + "bonus_id": param.BonusID, + "type": param.Type, + }) + + n := &domain.Notification{ + RecipientID: param.UserID, + DeliveryStatus: domain.DeliveryStatusPending, + IsRead: false, + Type: domain.NOTIFICATION_TYPE_BONUS_AWARDED, + Level: domain.NotificationLevelSuccess, + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: channel, + Payload: domain.NotificationPayload{ + Headline: headline, + Message: message, + }, + Priority: 2, + Metadata: raw, + } + if err := s.notificationSvc.SendNotification(ctx, n); err != nil { + return err + } + } + + return nil +} diff --git a/internal/services/bonus/port.go b/internal/services/bonus/port.go index 3dafb67..4bbd877 100644 --- a/internal/services/bonus/port.go +++ b/internal/services/bonus/port.go @@ -8,8 +8,8 @@ import ( type BonusStore interface { CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) (domain.UserBonus, error) - GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error) - GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error) + GetAllUserBonuses(ctx context.Context, filter domain.BonusFilter) ([]domain.UserBonus, error) + GetBonusCount(ctx context.Context, filter domain.BonusFilter) (int64, error) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error) GetBonusStats(ctx context.Context, filter domain.BonusFilter) (domain.BonusStats, error) UpdateUserBonus(ctx context.Context, bonusID int64, IsClaimed bool) error diff --git a/internal/services/bonus/service.go b/internal/services/bonus/service.go index 089c1c7..047fb7c 100644 --- a/internal/services/bonus/service.go +++ b/internal/services/bonus/service.go @@ -3,29 +3,32 @@ package bonus import ( "context" "errors" + "fmt" "math" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" - "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" + notificationservice "github.com/SamuelTariku/FortuneBet-Backend/internal/services/notification" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/settings" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/wallet" "go.uber.org/zap" ) type Service struct { - bonusStore BonusStore - walletSvc *wallet.Service - settingSvc *settings.Service - mongoLogger *zap.Logger + bonusStore BonusStore + walletSvc *wallet.Service + settingSvc *settings.Service + notificationSvc *notificationservice.Service + mongoLogger *zap.Logger } -func NewService(bonusStore BonusStore, walletSvc *wallet.Service, settingSvc *settings.Service, mongoLogger *zap.Logger) *Service { +func NewService(bonusStore BonusStore, walletSvc *wallet.Service, settingSvc *settings.Service, notificationSvc *notificationservice.Service, mongoLogger *zap.Logger) *Service { return &Service{ - bonusStore: bonusStore, - walletSvc: walletSvc, - settingSvc: settingSvc, - mongoLogger: mongoLogger, + bonusStore: bonusStore, + walletSvc: walletSvc, + settingSvc: settingSvc, + notificationSvc: notificationSvc, + mongoLogger: mongoLogger, } } @@ -34,7 +37,7 @@ var ( ErrWelcomeBonusCountReached = errors.New("welcome bonus max deposit count reached") ) -func (s *Service) ProcessWelcomeBonus(ctx context.Context, amount domain.Currency, companyID int64, userID int64) error { +func (s *Service) CreateWelcomeBonus(ctx context.Context, amount domain.Currency, companyID int64, userID int64) error { settingsList, err := s.settingSvc.GetOverrideSettingsList(ctx, companyID) if err != nil { s.mongoLogger.Error("Failed to get settings", @@ -65,11 +68,10 @@ func (s *Service) ProcessWelcomeBonus(ctx context.Context, amount domain.Currenc newBalance := math.Min(float64(amount)*float64(settingsList.WelcomeBonusMultiplier), float64(settingsList.WelcomeBonusCap)) - _, err = s.CreateUserBonus(ctx, domain.CreateBonus{ + bonus, err := s.CreateUserBonus(ctx, domain.CreateBonus{ Name: "Welcome Bonus", - Description: "Awarded when the user logged in for the first time", + Description: fmt.Sprintf("Awarded for deposit number (%v / %v)", stats.TotalDeposits, settingsList.WelcomeBonusCount), UserID: userID, - BonusCode: helpers.GenerateFastCode(), RewardAmount: domain.Currency(newBalance), ExpiresAt: time.Now().Add(time.Duration(settingsList.WelcomeBonusExpire) * 24 * time.Hour), }) @@ -78,26 +80,73 @@ func (s *Service) ProcessWelcomeBonus(ctx context.Context, amount domain.Currenc return err } - // TODO: Add a claim function that adds to the static wallet when the user inputs his bonus code - // _, err = s.walletSvc.AddToWallet(ctx, wallet.StaticID, domain.ToCurrency(float32(newBalance)), domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, - // fmt.Sprintf("Added %v to static wallet because of deposit bonus using multiplier %v", newBalance, settingsList.WelcomeBonusMultiplier), - // ) - // if err != nil { - // return err - // } + err = s.SendBonusNotification(ctx, SendBonusNotificationParam{ + BonusID: bonus.ID, + UserID: userID, + Type: domain.DepositBonus, + Amount: domain.Currency(newBalance), + SendEmail: true, + SendSMS: false, + }) + + if err != nil { + return err + } return nil +} +var ( + ErrBonusIsAlreadyClaimed = errors.New("bonus is already claimed") + ErrBonusUserIDNotMatch = errors.New("bonus user id is not a match") +) + +func (s *Service) ProcessBonusClaim(ctx context.Context, bonusID, userID int64) error { + + bonus, err := s.GetBonusByID(ctx, bonusID) + + if err != nil { + return err + } + + if bonus.UserID != userID { + + } + if bonus.IsClaimed { + return ErrBonusIsAlreadyClaimed + } + + wallet, err := s.walletSvc.GetCustomerWallet(ctx, bonus.UserID) + + if err != nil { + return err + } + + _, err = s.walletSvc.AddToWallet( + ctx, wallet.StaticID, bonus.RewardAmount, + domain.ValidInt64{}, domain.TRANSFER_DIRECT, domain.PaymentDetails{}, + fmt.Sprintf("Added %v to bonus wallet due to %v", bonus.RewardAmount, bonus.Type), + ) + if err != nil { + return err + } + + if err := s.UpdateUserBonus(ctx, bonusID, true); err != nil { + return err + } + + return nil } func (s *Service) CreateUserBonus(ctx context.Context, bonus domain.CreateBonus) (domain.UserBonus, error) { return s.bonusStore.CreateUserBonus(ctx, bonus) } -func (s *Service) GetAllUserBonuses(ctx context.Context) ([]domain.UserBonus, error) { - return s.bonusStore.GetAllUserBonuses(ctx) +func (s *Service) GetAllUserBonuses(ctx context.Context, filter domain.BonusFilter) ([]domain.UserBonus, error) { + return s.bonusStore.GetAllUserBonuses(ctx, filter) } -func (s *Service) GetBonusesByUserID(ctx context.Context, userID int64) ([]domain.UserBonus, error) { - return s.bonusStore.GetBonusesByUserID(ctx, userID) + +func (s *Service) GetBonusCount(ctx context.Context, filter domain.BonusFilter) (int64, error) { + return s.bonusStore.GetBonusCount(ctx, filter) } func (s *Service) GetBonusByID(ctx context.Context, bonusID int64) (domain.UserBonus, error) { return s.bonusStore.GetBonusByID(ctx, bonusID) diff --git a/internal/services/notfication/service.go b/internal/services/notfication/service.go deleted file mode 100644 index c6a5457..0000000 --- a/internal/services/notfication/service.go +++ /dev/null @@ -1,475 +0,0 @@ -package notificationservice - -import ( - "context" - "encoding/json" - "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/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 - connections sync.Map - notificationCh chan *domain.Notification - stopCh chan struct{} - config *config.Config - logger *slog.Logger - redisClient *redis.Client -} - -func New(repo repository.NotificationRepository, logger *slog.Logger, cfg *config.Config) *Service { - hub := ws.NewNotificationHub() - rdb := redis.NewClient(&redis.Options{ - Addr: cfg.RedisAddr, // e.g., "redis:6379" - }) - - svc := &Service{ - repo: repo, - Hub: hub, - logger: logger, - connections: sync.Map{}, - notificationCh: make(chan *domain.Notification, 1000), - stopCh: make(chan struct{}), - 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) { - if c == nil { - s.logger.Warn("[NotificationSvc.AddConnection] Attempted to add nil WebSocket connection", "recipientID", recipientID) - return - } - - s.connections.Store(recipientID, c) - s.logger.Info("[NotificationSvc.AddConnection] Added WebSocket connection", "recipientID", recipientID) -} - -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.logger.Error("[NotificationSvc.SendNotification] Failed to create notification", "id", notification.ID, "error", err) - 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.logger.Warn("[NotificationSvc.SendNotification] Notification channel full, dropping notification", "id", notification.ID) - } - - 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.logger.Error("[NotificationSvc.MarkAsRead] Failed to mark notification as read", "notificationID", notificationID, "recipientID", recipientID, "error", err) - return err - } - - // count, err := s.repo.CountUnreadNotifications(ctx, recipientID) - // if err != nil { - // s.logger.Error("[NotificationSvc.MarkAsRead] Failed to count unread notifications", "recipientID", recipientID, "error", err) - // return err - // } - - // s.Hub.Broadcast <- map[string]interface{}{ - // "type": "COUNT_NOT_OPENED_NOTIFICATION", - // "recipient_id": recipientID, - // "payload": map[string]int{ - // "not_opened_notifications_count": int(count), - // }, - // } - - s.logger.Info("[NotificationSvc.MarkAsRead] Notification marked as read", "notificationID", notificationID, "recipientID", recipientID) - } - - return nil -} - -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.logger.Error("[NotificationSvc.ListNotifications] Failed to list notifications", "recipientID", recipientID, "limit", limit, "offset", offset, "error", err) - return nil, err - } - s.logger.Info("[NotificationSvc.ListNotifications] Successfully listed notifications", "recipientID", recipientID, "count", len(notifications)) - 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.logger.Error("[NotificationSvc.ListNotifications] Failed to get all notifications") - return nil, err - } - s.logger.Info("[NotificationSvc.ListNotifications] Successfully retrieved all notifications", "count", len(notifications)) - return notifications, nil -} - -func (s *Service) ConnectWebSocket(ctx context.Context, recipientID int64, c *websocket.Conn) error { - s.addConnection(recipientID, c) - s.logger.Info("[NotificationSvc.ConnectWebSocket] WebSocket connection established", "recipientID", recipientID) - 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) - } -} - -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.logger.Info("[NotificationSvc.StartWorker] Worker stopped") - 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.SendSMS(ctx, notification.RecipientID, notification.Payload.Message) - if err != nil { - notification.DeliveryStatus = domain.DeliveryStatusFailed - } else { - notification.DeliveryStatus = domain.DeliveryStatusSent - } - case domain.DeliveryChannelEmail: - err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message) - if err != nil { - notification.DeliveryStatus = domain.DeliveryStatusFailed - } else { - notification.DeliveryStatus = domain.DeliveryStatusSent - } - default: - if notification.DeliveryChannel != domain.DeliveryChannelInApp { - s.logger.Warn("[NotificationSvc.HandleNotification] Unsupported delivery channel", "channel", notification.DeliveryChannel) - notification.DeliveryStatus = domain.DeliveryStatusFailed - } - } - - if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { - s.logger.Error("[NotificationSvc.HandleNotification] Failed to update notification status", "id", notification.ID, "error", err) - } -} - -func (s *Service) startRetryWorker() { - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - s.retryFailedNotifications() - case <-s.stopCh: - s.logger.Info("[NotificationSvc.StartRetryWorker] Retry worker stopped") - return - } - } -} - -func (s *Service) retryFailedNotifications() { - ctx := context.Background() - failedNotifications, err := s.repo.ListFailedNotifications(ctx, 100) - if err != nil { - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to list failed notifications", "error", err) - 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.SendSMS(ctx, notification.RecipientID, notification.Payload.Message); err == nil { - notification.DeliveryStatus = domain.DeliveryStatusSent - if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) - } - s.logger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", "id", notification.ID) - return - } - case domain.DeliveryChannelEmail: - if err := s.SendEmail(ctx, notification.RecipientID, notification.Payload.Headline, notification.Payload.Message); err == nil { - notification.DeliveryStatus = domain.DeliveryStatusSent - if _, err := s.repo.UpdateNotificationStatus(ctx, notification.ID, string(notification.DeliveryStatus), notification.IsRead, notification.Metadata); err != nil { - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Failed to update after retry", "id", notification.ID, "error", err) - } - s.logger.Info("[NotificationSvc.RetryFailedNotifications] Successfully retried notification", "id", notification.ID) - return - } - } - } - s.logger.Error("[NotificationSvc.RetryFailedNotifications] Max retries reached for notification", "id", notification.ID) - }(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) - 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) UpdateLiveMetricForWallet(ctx context.Context, wallet domain.Wallet) { - var ( - payload domain.LiveWalletMetrics - event map[string]interface{} - key = "live_metrics" - ) - - // Try company first - company, companyErr := s.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.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) - 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) - } - - // 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) - } - - // Broadcast over WebSocket - s.Hub.Broadcast <- event -} - -func (s *Service) GetCompanyByWalletID(ctx context.Context, walletID int64) (domain.Company, error) { - return s.GetCompanyByWalletID(ctx, walletID) -} - -func (s *Service) GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) { - return s.GetBranchByWalletID(ctx, walletID) -} diff --git a/internal/services/virtualGame/veli/service.go b/internal/services/virtualGame/veli/service.go index d7dc327..df0ac27 100644 --- a/internal/services/virtualGame/veli/service.go +++ b/internal/services/virtualGame/veli/service.go @@ -22,23 +22,23 @@ var ( type Service struct { virtualGameSvc virtualgameservice.VirtualGameService - repo repository.VirtualGameRepository - client *Client - walletSvc *wallet.Service - transfetStore wallet.TransferStore - cfg *config.Config + repo repository.VirtualGameRepository + client *Client + walletSvc *wallet.Service + transfetStore wallet.TransferStore + cfg *config.Config } -func New(virtualGameSvc virtualgameservice.VirtualGameService,repo repository.VirtualGameRepository,client *Client, walletSvc *wallet.Service, transferStore wallet.TransferStore, cfg *config.Config) *Service { +func New(virtualGameSvc virtualgameservice.VirtualGameService, repo repository.VirtualGameRepository, client *Client, walletSvc *wallet.Service, transferStore wallet.TransferStore, cfg *config.Config) *Service { return &Service{ virtualGameSvc: virtualGameSvc, - repo: repo, - client: client, - walletSvc: walletSvc, - transfetStore: transferStore, - cfg: cfg, + repo: repo, + client: client, + walletSvc: walletSvc, + transfetStore: transferStore, + cfg: cfg, } -} +} func (s *Service) GetProviders(ctx context.Context, req domain.ProviderRequest) (*domain.ProviderResponse, error) { // Always mirror request body fields into sigParams @@ -80,6 +80,8 @@ func (s *Service) GetGames(ctx context.Context, req domain.GameListRequest) ([]d sigParams := map[string]any{ "brandId": req.BrandID, "providerId": req.ProviderID, + "size": req.Size, + "page": req.Page, } // 3. Call external API @@ -128,7 +130,6 @@ func (s *Service) StartGame(ctx context.Context, req domain.GameStartRequest) (* return &res, nil } - func (s *Service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) (*domain.GameStartResponse, error) { // 1. Check if provider is enabled in DB // provider, err := s.repo.GetVirtualGameProviderByID(ctx, req.ProviderID) @@ -160,7 +161,6 @@ func (s *Service) StartDemoGame(ctx context.Context, req domain.DemoGameRequest) return &res, nil } - func (s *Service) GetBalance(ctx context.Context, req domain.BalanceRequest) (*domain.BalanceResponse, error) { // Retrieve player's real balance from wallet Service playerIDInt64, err := strconv.ParseInt(req.PlayerID, 10, 64) diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 1bebede..1655050 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -26,32 +26,32 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S spec string task func() }{ - // { - // spec: "0 0 * * * *", // Every 1 hour - // task: func() { - // mongoLogger.Info("Began fetching upcoming events cron task") - // if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { - // mongoLogger.Error("Failed to fetch upcoming events", - // zap.Error(err), - // ) - // } else { - // mongoLogger.Info("Completed fetching upcoming events without errors") - // } - // }, - // }, - // { - // spec: "0 0 * * * *", // Every 1 hour (since its takes that long to fetch all the events) - // task: func() { - // mongoLogger.Info("Began fetching non live odds cron task") - // if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { - // mongoLogger.Error("Failed to fetch non live odds", - // zap.Error(err), - // ) - // } else { - // mongoLogger.Info("Completed fetching non live odds without errors") - // } - // }, - // }, + { + spec: "0 0 * * * *", // Every 1 hour + task: func() { + mongoLogger.Info("Began fetching upcoming events cron task") + if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + mongoLogger.Error("Failed to fetch upcoming events", + zap.Error(err), + ) + } else { + mongoLogger.Info("Completed fetching upcoming events without errors") + } + }, + }, + { + spec: "0 0 * * * *", // Every 1 hour (since its takes that long to fetch all the events) + task: func() { + mongoLogger.Info("Began fetching non live odds cron task") + if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + mongoLogger.Error("Failed to fetch non live odds", + zap.Error(err), + ) + } else { + mongoLogger.Info("Completed fetching non live odds without errors") + } + }, + }, { spec: "0 */5 * * * *", // Every 5 Minutes task: func() { diff --git a/internal/web_server/handlers/bonus.go b/internal/web_server/handlers/bonus.go index 80374cc..a273dff 100644 --- a/internal/web_server/handlers/bonus.go +++ b/internal/web_server/handlers/bonus.go @@ -1,12 +1,13 @@ package handlers -// import ( -// "time" +import ( + "strconv" -// "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" -// "github.com/gofiber/fiber/v2" -// "go.uber.org/zap" -// ) + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) // func (h *Handler) CreateBonusMultiplier(c *fiber.Ctx) error { // var req struct { @@ -96,3 +97,110 @@ package handlers // return response.WriteJSON(c, fiber.StatusOK, "Updated bonus multiplier successfully", nil, nil) // } + +func (h *Handler) GetBonusesByUserID(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.InternalServerErrorLogger().Error("Invalid user ID in context", + zap.Int64("userID", userID), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification") + } + + page := c.QueryInt("page", 1) + pageSize := c.QueryInt("page_size", 10) + limit := domain.ValidInt{ + Value: pageSize, + Valid: true, + } + offset := domain.ValidInt{ + Value: page - 1, + Valid: true, + } + + filter := domain.BonusFilter{ + UserID: domain.ValidInt64{ + Value: userID, + Valid: true, + }, + Limit: limit, + Offset: offset, + } + + bonuses, err := h.bonusSvc.GetAllUserBonuses(c.Context(), filter) + + if err != nil { + h.InternalServerErrorLogger().Error("Failed to bonus by userID", zap.Int64("userId", userID)) + return fiber.NewError(fiber.StatusInternalServerError, "failed to get bonus by user ID") + } + + count, err := h.bonusSvc.GetBonusCount(c.Context(), filter) + if err != nil { + h.InternalServerErrorLogger().Error("Failed to get bonus count", zap.Int64("userId", userID)) + return fiber.NewError(fiber.StatusInternalServerError, "failed to get bonus count by user ID") + } + + res := domain.ConvertToBonusResList(bonuses) + + return response.WritePaginatedJSON(c, fiber.StatusOK, "Fetched User Bonuses", res, nil, page, int(count)) + +} + +func (h *Handler) GetBonusStats(c *fiber.Ctx) error { + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.InternalServerErrorLogger().Error("Invalid user ID in context", + zap.Int64("userID", userID), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification") + } + + stats, err := h.bonusSvc.GetBonusStats(c.Context(), domain.BonusFilter{ + UserID: domain.ValidInt64{ + Value: userID, + Valid: true, + }, + }) + + if err != nil { + h.InternalServerErrorLogger().Error("Failed to get bonus stats", + zap.Int64("userID", userID), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get bonus stats") + } + + res := domain.ConvertToBonusStatsRes(stats) + + return response.WriteJSON(c, fiber.StatusOK, "Get Bonus Stats", res, nil) +} + +// bonus/:id/claim +func (h *Handler) ClaimBonus(c *fiber.Ctx) error { + bonusIDParam := c.Params("id") + bonusID, err := strconv.ParseInt(bonusIDParam, 10, 64) + if err != nil { + h.BadRequestLogger().Error("Invalid bonus ID", + zap.Int64("bonusID", bonusID), + ) + return fiber.NewError(fiber.StatusBadRequest, "Invalid bonus id") + } + + userID, ok := c.Locals("user_id").(int64) + if !ok || userID == 0 { + h.InternalServerErrorLogger().Error("Invalid user ID in context", + zap.Int64("userID", userID), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification") + } + + if err := h.bonusSvc.ProcessBonusClaim(c.Context(), bonusID, userID); err != nil { + h.InternalServerErrorLogger().Error("Failed to update bonus claim", + zap.Int64("userID", userID), + zap.Int64("bonusID", bonusID), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update bonus claim") + } + + return response.WriteJSON(c, fiber.StatusOK, "Bonus has successfully been claimed", nil, nil) + +} diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 28b31e4..07aeddc 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -206,7 +206,9 @@ func (a *App) initAppRoutes() { a.fiber.Get("/raffle-ticket/unsuspend/:id", a.authMiddleware, h.UnSuspendRaffleTicket) // Bonus Routes - // groupV1.Get("/bonus", a.authMiddleware, h.GetBonusMultiplier) + groupV1.Get("/bonus", a.authMiddleware, h.GetBonusesByUserID) + groupV1.Get("/bonus/stats", a.authMiddleware, h.GetBonusStats) + groupV1.Post("/bonus/claim/:id", a.authMiddleware, h.ClaimBonus) // groupV1.Post("/bonus/create", a.authMiddleware, h.CreateBonusMultiplier) // groupV1.Put("/bonus/update", a.authMiddleware, h.UpdateBonusMultiplier)