From aa4bddef58720bac186a07536571d5eb5e3ea32f Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Sun, 13 Jul 2025 13:46:38 +0300 Subject: [PATCH 01/10] feat: branch location list --- db/migrations/000007_setting_data.up.sql | 5 +-- db/query/location.sql | 7 ++++ gen/db/location.sql.go | 41 +++++++++++++++++++ internal/domain/branch.go | 5 +++ internal/repository/location.go | 30 ++++++++++++++ internal/services/bet/service.go | 2 +- internal/services/branch/branch_locations.go | 11 +++++ internal/services/branch/port.go | 2 + internal/services/branch/service.go | 1 + .../web_server/handlers/branch_handler.go | 33 +++++++++++++++ internal/web_server/routes.go | 5 ++- 11 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 db/query/location.sql create mode 100644 gen/db/location.sql.go create mode 100644 internal/repository/location.go create mode 100644 internal/services/branch/branch_locations.go diff --git a/db/migrations/000007_setting_data.up.sql b/db/migrations/000007_setting_data.up.sql index adbc6c2..f69156b 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -4,8 +4,7 @@ VALUES ('max_number_of_outcomes', '30'), ('bet_amount_limit', '100000'), ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), - ('amount_for_bet_referral', '1000000') - ('cashback_amount_cap', '1000') - ON CONFLICT (key) DO + ('amount_for_bet_referral', '1000000'), + ('cashback_amount_cap', '1000') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; \ No newline at end of file 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/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/internal/domain/branch.go b/internal/domain/branch.go index 610af17..8064410 100644 --- a/internal/domain/branch.go +++ b/internal/domain/branch.go @@ -11,6 +11,11 @@ type Branch struct { IsSelfOwned bool } +type BranchLocation struct { + Key string `json:"key" example:"addis_ababa" ` + Name string `json:"name" example:"Addis Ababa"` +} + type BranchFilter struct { CompanyID ValidInt64 IsActive ValidBool 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/services/bet/service.go b/internal/services/bet/service.go index a3952db..891d8a5 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -1033,7 +1033,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/web_server/handlers/branch_handler.go b/internal/web_server/handlers/branch_handler.go index 4f42491..d085a38 100644 --- a/internal/web_server/handlers/branch_handler.go +++ b/internal/web_server/handlers/branch_handler.go @@ -619,6 +619,39 @@ 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/routes.go b/internal/web_server/routes.go index d5bf75a..ba725df 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -171,9 +171,10 @@ 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) From e3545f3f8ca7081bd6f144a799c488cb67aae6a6 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Mon, 14 Jul 2025 19:30:37 +0300 Subject: [PATCH 02/10] flag too many outcomes --- db/migrations/000001_fortune.down.sql | 2 + db/migrations/000001_fortune.up.sql | 17 +++++++- db/migrations/000007_setting_data.up.sql | 2 +- db/query/bet.sql | 2 +- db/query/flags.sql | 8 ++++ gen/db/bet.sql.go | 8 ++-- gen/db/flags.sql.go | 42 ++++++++++++++++++++ gen/db/models.go | 9 +++++ internal/domain/bet.go | 25 +++++++++--- internal/repository/bet.go | 44 ++++++++++++++++++++- internal/services/bet/port.go | 3 +- internal/services/bet/service.go | 43 ++++++++++++++------ internal/web_server/handlers/bet_handler.go | 2 +- internal/web_server/routes.go | 2 +- reports/report_5min_2025-07-14_18-11.csv | 16 ++++++++ reports/report_5min_2025-07-14_18-15.csv | 16 ++++++++ reports/report_5min_2025-07-14_18-16.csv | 16 ++++++++ 17 files changed, 227 insertions(+), 30 deletions(-) create mode 100644 db/query/flags.sql create mode 100644 gen/db/flags.sql.go create mode 100644 reports/report_5min_2025-07-14_18-11.csv create mode 100644 reports/report_5min_2025-07-14_18-15.csv create mode 100644 reports/report_5min_2025-07-14_18-16.csv 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..e1b8391 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -208,7 +208,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, @@ -319,6 +319,21 @@ 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 adbc6c2..22f5974 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -4,7 +4,7 @@ VALUES ('max_number_of_outcomes', '30'), ('bet_amount_limit', '100000'), ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), - ('amount_for_bet_referral', '1000000') + ('amount_for_bet_referral', '1000000'), ('cashback_amount_cap', '1000') ON CONFLICT (key) DO UPDATE diff --git a/db/query/bet.sql b/db/query/bet.sql index 8cca28e..588e3c0 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -101,7 +101,7 @@ WHERE (event_id = $1) SELECT * FROM bet_outcomes WHERE bet_id = $1; --- name: GetBetCount :one +-- name: GetBetCountByUserID :one SELECT COUNT(*) FROM bets WHERE user_id = $1 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/gen/db/bet.sql.go b/gen/db/bet.sql.go index cae3d8b..965ccdf 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -282,20 +282,20 @@ func (q *Queries) GetBetByUserID(ctx context.Context, userID int64) ([]BetWithOu return items, nil } -const GetBetCount = `-- name: GetBetCount :one +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 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/models.go b/gen/db/models.go index cccf340..66a9bed 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -276,6 +276,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"` diff --git a/internal/domain/bet.go b/internal/domain/bet.go index a3f0c2b..8b9ae32 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" validate:"required" 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"` diff --git a/internal/repository/bet.go b/internal/repository/bet.go index bdf83d3..75714f8 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, }) diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index 9a116be..e56cd40 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -10,13 +10,14 @@ 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) + GetBetCountByUserID(ctx context.Context, userID int64, 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..81cd6bf 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -269,7 +269,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), @@ -360,15 +360,15 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID case domain.RoleCustomer: // Only the customer is able to create a online bet newBet.IsShopBet = false - err = s.DeductBetFromCustomerWallet(ctx, req.Amount, userID) - if err != nil { - s.mongoLogger.Error("customer wallet deduction failed", - zap.Float32("amount", req.Amount), - zap.Int64("user_id", userID), - zap.Error(err), - ) - return domain.CreateBetRes{}, err - } + // err = s.DeductBetFromCustomerWallet(ctx, req.Amount, userID) + // if err != nil { + // s.mongoLogger.Error("customer wallet deduction failed", + // zap.Float32("amount", req.Amount), + // zap.Int64("user_id", userID), + // zap.Error(err), + // ) + // return domain.CreateBetRes{}, err + // } default: s.mongoLogger.Error("unknown role type", zap.String("role", string(role)), @@ -398,6 +398,23 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return domain.CreateBetRes{}, 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), + ) + } + } + res := domain.ConvertCreateBet(bet, rows) return res, nil @@ -716,7 +733,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 +816,8 @@ 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) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { diff --git a/internal/web_server/handlers/bet_handler.go b/internal/web_server/handlers/bet_handler.go index 0c335a6..024c2a7 100644 --- a/internal/web_server/handlers/bet_handler.go +++ b/internal/web_server/handlers/bet_handler.go @@ -118,7 +118,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, diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index d5bf75a..5d08063 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -161,7 +161,7 @@ func (a *App) initAppRoutes() { groupV1.Put("/leagues/:id/featured", h.SetLeagueFeatured) groupV1.Get("/result/:id", h.GetResultsByEventID) - + // Branch groupV1.Post("/branch", a.authMiddleware, h.CreateBranch) groupV1.Get("/branch", a.authMiddleware, h.GetAllBranches) diff --git a/reports/report_5min_2025-07-14_18-11.csv b/reports/report_5min_2025-07-14_18-11.csv new file mode 100644 index 0000000..c9ad19d --- /dev/null +++ b/reports/report_5min_2025-07-14_18-11.csv @@ -0,0 +1,16 @@ +Sports Betting Reports (Periodic) +Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets +5min,0,0.00,0.00,0.00,0.00,0.00,0 + +Virtual Game Reports (Periodic) +Game Name,Number of Bets,Total Transaction Sum + +Company Reports (Periodic) +Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Branch Reports (Periodic) +Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Total Summary +Total Bets,Total Cash In,Total Cash Out,Total Cash Backs +0,0.00,0.00,0.00 diff --git a/reports/report_5min_2025-07-14_18-15.csv b/reports/report_5min_2025-07-14_18-15.csv new file mode 100644 index 0000000..c9ad19d --- /dev/null +++ b/reports/report_5min_2025-07-14_18-15.csv @@ -0,0 +1,16 @@ +Sports Betting Reports (Periodic) +Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets +5min,0,0.00,0.00,0.00,0.00,0.00,0 + +Virtual Game Reports (Periodic) +Game Name,Number of Bets,Total Transaction Sum + +Company Reports (Periodic) +Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Branch Reports (Periodic) +Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Total Summary +Total Bets,Total Cash In,Total Cash Out,Total Cash Backs +0,0.00,0.00,0.00 diff --git a/reports/report_5min_2025-07-14_18-16.csv b/reports/report_5min_2025-07-14_18-16.csv new file mode 100644 index 0000000..c9ad19d --- /dev/null +++ b/reports/report_5min_2025-07-14_18-16.csv @@ -0,0 +1,16 @@ +Sports Betting Reports (Periodic) +Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets +5min,0,0.00,0.00,0.00,0.00,0.00,0 + +Virtual Game Reports (Periodic) +Game Name,Number of Bets,Total Transaction Sum + +Company Reports (Periodic) +Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Branch Reports (Periodic) +Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs + +Total Summary +Total Bets,Total Cash In,Total Cash Out,Total Cash Backs +0,0.00,0.00,0.00 From 472e4490f89f9568cc9ee04cf4ee49d612187b4a Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Mon, 14 Jul 2025 20:52:58 +0300 Subject: [PATCH 03/10] fix: web integration issues --- .gitignore | 2 +- db/migrations/000001_fortune.up.sql | 15 +- db/query/branch.sql | 6 +- db/query/events.sql | 22 +- gen/db/branch.sql.go | 36 +-- gen/db/events.sql.go | 80 ++++--- gen/db/models.go | 3 +- internal/domain/bet.go | 13 +- internal/domain/branch.go | 210 ++++++++++++++---- internal/domain/event.go | 6 +- internal/repository/branch.go | 105 +-------- internal/repository/event.go | 36 +-- internal/services/event/port.go | 2 +- internal/services/event/service.go | 4 +- internal/web_server/handlers/auth_handler.go | 131 ++++++++++- internal/web_server/handlers/bet_handler.go | 52 ++++- internal/web_server/handlers/event_handler.go | 46 ++-- internal/web_server/routes.go | 12 +- 18 files changed, 527 insertions(+), 254 deletions(-) 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/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 5a74346..a5ebe1c 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -216,7 +216,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 +262,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 +294,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, 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/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/models.go b/gen/db/models.go index cccf340..15f24e5 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 { diff --git a/internal/domain/bet.go b/internal/domain/bet.go index a3f0c2b..53522ab 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -93,16 +93,16 @@ 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 RandomBetReq struct { BranchID int64 `json:"branch_id" validate:"required" example:"1"` @@ -117,6 +117,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 +141,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 8064410..ded005f 100644 --- a/internal/domain/branch.go +++ b/internal/domain/branch.go @@ -1,14 +1,20 @@ 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 { @@ -38,6 +44,7 @@ type BranchDetail struct { ManagerName string ManagerPhoneNumber string WalletIsActive bool + ProfitPercentage float32 } type SupportedOperation struct { @@ -53,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 { @@ -81,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 { @@ -120,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 { @@ -143,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, } } @@ -172,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/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/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/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/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 9507669..46ef873 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" @@ -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/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/routes.go b/internal/web_server/routes.go index ba725df..232490b 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -69,7 +69,8 @@ func (a *App) initAppRoutes() { }) }) // 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 { @@ -153,7 +154,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) @@ -161,7 +162,7 @@ func (a *App) initAppRoutes() { groupV1.Put("/leagues/:id/featured", h.SetLeagueFeatured) groupV1.Get("/result/:id", h.GetResultsByEventID) - + // Branch groupV1.Post("/branch", a.authMiddleware, h.CreateBranch) groupV1.Get("/branch", a.authMiddleware, h.GetAllBranches) @@ -171,10 +172,10 @@ 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) groupV1.Get("/branchLocation", a.authMiddleware, h.GetAllBranchLocations) - + groupV1.Get("/branch/:id/cashiers", a.authMiddleware, h.GetBranchCashiers) groupV1.Get("/branchCashier", a.authMiddleware, h.GetBranchForCashier) @@ -204,6 +205,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) From bbd73576af273c8ba5a0e6e3189e078cd7062d03 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Mon, 14 Jul 2025 21:00:40 +0300 Subject: [PATCH 04/10] fix: getting it ready for v1.0dev10 deployment --- internal/web_server/cron.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 67df201..8d45a17 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() { From f63e35fb4effe8fbd57280dc2c9be837f5f52c72 Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Mon, 14 Jul 2025 23:05:17 +0300 Subject: [PATCH 05/10] flag multiple bets with same bet outcomes --- db/query/bet.sql | 4 ++ gen/db/bet.sql.go | 13 ++++++ internal/repository/bet.go | 9 +++++ internal/services/bet/port.go | 1 + internal/services/bet/service.go | 50 +++++++++++++++++++----- reports/report_5min_2025-07-14_18-11.csv | 16 -------- reports/report_5min_2025-07-14_18-15.csv | 16 -------- reports/report_5min_2025-07-14_18-16.csv | 16 -------- 8 files changed, 68 insertions(+), 57 deletions(-) delete mode 100644 reports/report_5min_2025-07-14_18-11.csv delete mode 100644 reports/report_5min_2025-07-14_18-15.csv delete mode 100644 reports/report_5min_2025-07-14_18-16.csv diff --git a/db/query/bet.sql b/db/query/bet.sql index 588e3c0..fdc74d8 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -106,6 +106,10 @@ 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/gen/db/bet.sql.go b/gen/db/bet.sql.go index 965ccdf..c8d18d5 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -282,6 +282,19 @@ func (q *Queries) GetBetByUserID(ctx context.Context, userID int64) ([]BetWithOu return items, nil } +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 diff --git a/internal/repository/bet.go b/internal/repository/bet.go index 75714f8..f61e5c8 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -290,6 +290,15 @@ func (s *Store) GetBetCountByUserID(ctx context.Context, UserID int64, outcomesH 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) UpdateCashOut(ctx context.Context, id int64, cashedOut bool) error { err := s.queries.UpdateCashOut(ctx, dbgen.UpdateCashOutParams{ ID: id, diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index e56cd40..6c79f0a 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -18,6 +18,7 @@ type BetStore interface { GetBetOutcomeByEventID(ctx context.Context, eventID int64, is_filtered bool) ([]domain.BetOutcome, error) GetBetOutcomeByBetID(ctx context.Context, betID int64) ([]domain.BetOutcome, 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 81cd6bf..7a2f0a4 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -360,15 +360,15 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID case domain.RoleCustomer: // Only the customer is able to create a online bet newBet.IsShopBet = false - // err = s.DeductBetFromCustomerWallet(ctx, req.Amount, userID) - // if err != nil { - // s.mongoLogger.Error("customer wallet deduction failed", - // zap.Float32("amount", req.Amount), - // zap.Int64("user_id", userID), - // zap.Error(err), - // ) - // return domain.CreateBetRes{}, err - // } + err = s.DeductBetFromCustomerWallet(ctx, req.Amount, userID) + if err != nil { + s.mongoLogger.Error("customer wallet deduction failed", + zap.Float32("amount", req.Amount), + zap.Int64("user_id", userID), + zap.Error(err), + ) + return domain.CreateBetRes{}, err + } default: s.mongoLogger.Error("unknown role type", zap.String("role", string(role)), @@ -415,6 +415,34 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID } } + // 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 + } + + fmt.Println("total bet count: ", total_bet_count) + + if total_bet_count > 3 { + 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 @@ -820,6 +848,10 @@ func (s *Service) GetBetCountByUserID(ctx context.Context, UserID int64, outcome 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 { return s.betStore.UpdateCashOut(ctx, id, cashedOut) } diff --git a/reports/report_5min_2025-07-14_18-11.csv b/reports/report_5min_2025-07-14_18-11.csv deleted file mode 100644 index c9ad19d..0000000 --- a/reports/report_5min_2025-07-14_18-11.csv +++ /dev/null @@ -1,16 +0,0 @@ -Sports Betting Reports (Periodic) -Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets -5min,0,0.00,0.00,0.00,0.00,0.00,0 - -Virtual Game Reports (Periodic) -Game Name,Number of Bets,Total Transaction Sum - -Company Reports (Periodic) -Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Branch Reports (Periodic) -Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Total Summary -Total Bets,Total Cash In,Total Cash Out,Total Cash Backs -0,0.00,0.00,0.00 diff --git a/reports/report_5min_2025-07-14_18-15.csv b/reports/report_5min_2025-07-14_18-15.csv deleted file mode 100644 index c9ad19d..0000000 --- a/reports/report_5min_2025-07-14_18-15.csv +++ /dev/null @@ -1,16 +0,0 @@ -Sports Betting Reports (Periodic) -Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets -5min,0,0.00,0.00,0.00,0.00,0.00,0 - -Virtual Game Reports (Periodic) -Game Name,Number of Bets,Total Transaction Sum - -Company Reports (Periodic) -Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Branch Reports (Periodic) -Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Total Summary -Total Bets,Total Cash In,Total Cash Out,Total Cash Backs -0,0.00,0.00,0.00 diff --git a/reports/report_5min_2025-07-14_18-16.csv b/reports/report_5min_2025-07-14_18-16.csv deleted file mode 100644 index c9ad19d..0000000 --- a/reports/report_5min_2025-07-14_18-16.csv +++ /dev/null @@ -1,16 +0,0 @@ -Sports Betting Reports (Periodic) -Period,Total Bets,Total Cash Made,Total Cash Out,Total Cash Backs,Total Deposits,Total Withdrawals,Total Tickets -5min,0,0.00,0.00,0.00,0.00,0.00,0 - -Virtual Game Reports (Periodic) -Game Name,Number of Bets,Total Transaction Sum - -Company Reports (Periodic) -Company ID,Company Name,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Branch Reports (Periodic) -Branch ID,Branch Name,Company ID,Total Bets,Total Cash In,Total Cash Out,Total Cash Backs - -Total Summary -Total Bets,Total Cash In,Total Cash Out,Total Cash Backs -0,0.00,0.00,0.00 From 1c3f57519566b6e3c68ba250ec036f2ddbba7ead Mon Sep 17 00:00:00 2001 From: Asher Samuel Date: Tue, 15 Jul 2025 15:39:47 +0300 Subject: [PATCH 06/10] flag abused odd --- db/query/bet.sql | 4 ++++ gen/db/bet.sql.go | 13 ++++++++++++ internal/repository/bet.go | 9 +++++++++ internal/services/bet/port.go | 1 + internal/services/bet/service.go | 34 +++++++++++++++++++++++++++++--- 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/db/query/bet.sql b/db/query/bet.sql index fdc74d8..47018e1 100644 --- a/db/query/bet.sql +++ b/db/query/bet.sql @@ -101,6 +101,10 @@ WHERE (event_id = $1) SELECT * FROM bet_outcomes WHERE bet_id = $1; +-- name: GetBetOutcomeCountByOddID :one +SELECT COUNT(*) +FROM bet_outcomes +WHERE odd_id = $1; -- name: GetBetCountByUserID :one SELECT COUNT(*) FROM bets diff --git a/gen/db/bet.sql.go b/gen/db/bet.sql.go index c8d18d5..31ca511 100644 --- a/gen/db/bet.sql.go +++ b/gen/db/bet.sql.go @@ -410,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/internal/repository/bet.go b/internal/repository/bet.go index f61e5c8..2dee7b1 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -299,6 +299,15 @@ func (s *Store) GetBetCountByOutcomesHash(ctx context.Context, outcomesHash stri 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, diff --git a/internal/services/bet/port.go b/internal/services/bet/port.go index 6c79f0a..e29b68e 100644 --- a/internal/services/bet/port.go +++ b/internal/services/bet/port.go @@ -17,6 +17,7 @@ type BetStore interface { 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) + 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 diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 7a2f0a4..7804ec4 100644 --- a/internal/services/bet/service.go +++ b/internal/services/bet/service.go @@ -398,6 +398,36 @@ 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{ @@ -425,9 +455,7 @@ func (s *Service) PlaceBet(ctx context.Context, req domain.CreateBetReq, userID return domain.CreateBetRes{}, err } - fmt.Println("total bet count: ", total_bet_count) - - if total_bet_count > 3 { + if total_bet_count > 10 { flag := domain.CreateFlagReq{ BetID: bet.ID, OddID: 0, From b692a3b3179f76c08b25dd1efee3a4203e594fa1 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Wed, 16 Jul 2025 15:46:30 +0300 Subject: [PATCH 07/10] fix: use afrosms for now --- internal/web_server/handlers/user.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index 5344919..381078b 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)), @@ -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)), From 65bd5ab3f50f4e81520aa87bc2f004cd283ba2ce Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Sun, 20 Jul 2025 22:40:50 +0300 Subject: [PATCH 08/10] fix: added wallet type to wallet and other minor fixes --- db/migrations/000001_fortune.up.sql | 13 ++- db/migrations/000009_location_data.up.sql | 3 +- db/query/wallet.sql | 5 +- gen/db/models.go | 1 + gen/db/wallet.sql.go | 27 +++-- internal/domain/bet.go | 2 +- internal/domain/company.go | 5 +- internal/domain/otp.go | 2 +- internal/domain/wallet.go | 2 + internal/repository/wallet.go | 3 +- internal/services/user/common.go | 6 +- internal/services/wallet/wallet.go | 112 +++++++++++++++++-- internal/web_server/cron.go | 40 +++---- internal/web_server/handlers/auth_handler.go | 2 +- internal/web_server/handlers/report.go | 11 +- internal/web_server/handlers/shop_handler.go | 83 ++++++++++++++ internal/web_server/handlers/user.go | 111 +++++++++++++++++- internal/web_server/routes.go | 5 +- 18 files changed, 371 insertions(+), 62 deletions(-) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 77ef8ee..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 @@ -335,12 +336,16 @@ CREATE TABLE flags ( 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) + ( + bet_id IS NOT NULL + AND odd_id IS NULL + ) + OR ( + bet_id IS NULL + AND odd_id IS NOT NULL + ) ) ); -- Views 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/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/models.go b/gen/db/models.go index 705b55f..575526a 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -674,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/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 83c537e..bc6aae0 100644 --- a/internal/domain/bet.go +++ b/internal/domain/bet.go @@ -104,7 +104,7 @@ 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"` + BranchID *int64 `json:"branch_id,omitempty" example:"1"` } type CreateBetWithFastCodeReq struct { diff --git a/internal/domain/company.go b/internal/domain/company.go index b21e519..406f0fe 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"` diff --git a/internal/domain/otp.go b/internal/domain/otp.go index 23c8640..8eb4106 100644 --- a/internal/domain/otp.go +++ b/internal/domain/otp.go @@ -30,7 +30,7 @@ type OtpProvider string const ( TwilioSms OtpProvider = "twilio" - AfroMessage OtpProvider = "aformessage" + AfroMessage OtpProvider = "afro_message" ) type Otp struct { 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/wallet.go b/internal/repository/wallet.go index 4a6ae45..4aa764e 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), } } @@ -275,4 +277,3 @@ func (s *Store) GetTotalWallets(ctx context.Context, filter domain.ReportFilter) return total, nil } - diff --git a/internal/services/user/common.go b/internal/services/user/common.go index 0094210..683f39f 100644 --- a/internal/services/user/common.go +++ b/internal/services/user/common.go @@ -23,11 +23,11 @@ func (s *Service) SendOtp(ctx context.Context, sentTo string, otpFor domain.OtpF switch medium { case domain.OtpMediumSms: switch provider { - case "twilio": + case domain.TwilioSms: if err := s.SendTwilioSMSOTP(ctx, sentTo, message, provider); err != nil { return err } - case "afromessage": + case domain.AfroMessage: if err := s.SendAfroMessageSMSOTP(ctx, sentTo, message, provider); err != nil { return err } @@ -107,7 +107,7 @@ func (s *Service) SendTwilioSMSOTP(ctx context.Context, receiverPhone, message s _, err := client.Api.CreateMessage(params) if err != nil { - return fmt.Errorf("%s", "Error sending SMS message: %s" + err.Error()) + return fmt.Errorf("%s", "Error sending SMS message: %s"+err.Error()) } return nil diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index 9973a69..66f8bad 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -126,11 +126,29 @@ 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) + s.SendAdminWalletInsufficientNotification(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 { @@ -197,30 +215,28 @@ 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) 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 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 @@ -240,3 +256,85 @@ func (s *Service) SendAdminWalletLowNotification(ctx context.Context, adminWalle } 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.DeliveryChannelEmail, // 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 + adminRecipients, err := s.notificationStore.ListRecipientIDs(ctx, domain.NotificationRecieverSideAdmin) + if err != nil { + s.logger.Error("failed to get admin recipients", "error", err) + 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) + } + } + } + return nil +} + +func (s *Service) SendCustomerWalletInsufficientNotification(ctx context.Context, customerWallet domain.Wallet, amount domain.Currency) error { + // Send notification to admin team + adminNotification := &domain.Notification{ + RecipientID: customerWallet.UserID, + Type: domain.NOTIFICATION_TYPE_WALLET, + Level: domain.NotificationLevelError, + Reciever: domain.NotificationRecieverSideAdmin, + DeliveryChannel: domain.DeliveryChannelEmail, // 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", + 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, adminNotification); err != nil { + s.logger.Error("failed to send customer notification", + "admin_id", customerWallet.UserID, + "error", err) + } + return nil +} diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index 8d45a17..56565af 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() { @@ -114,10 +114,10 @@ func SetupReportCronJobs(ctx context.Context, reportService *report.Service) { spec string period string }{ - { - spec: "*/300 * * * * *", // Every 5 minutes (300 seconds) - period: "5min", - }, + // { + // spec: "*/300 * * * * *", // Every 5 minutes (300 seconds) + // period: "5min", + // }, { spec: "0 0 0 * * *", // Daily at midnight period: "daily", diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 46ef873..6d92b98 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -15,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"` } diff --git a/internal/web_server/handlers/report.go b/internal/web_server/handlers/report.go index e15faae..a34b8e5 100644 --- a/internal/web_server/handlers/report.go +++ b/internal/web_server/handlers/report.go @@ -73,21 +73,28 @@ func (h *Handler) GetDashboardReport(c *fiber.Ctx) error { func parseReportFilter(c *fiber.Ctx) (domain.ReportFilter, error) { var filter domain.ReportFilter var err error + role := c.Locals("role").(domain.Role) + + if c.Query("company_id") != "" && role == domain.RoleSuperAdmin { - if c.Query("company_id") != "" { 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 381078b..8779de9 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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", @@ -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 232490b..acb6a45 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -105,7 +105,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) @@ -174,6 +175,7 @@ func (a *App) initAppRoutes() { groupV1.Delete("/branch/:id", a.authMiddleware, h.DeleteBranch) groupV1.Get("/search/branch", a.authMiddleware, h.SearchBranch) + groupV1.Get("/branchLocation", a.authMiddleware, h.GetAllBranchLocations) groupV1.Get("/branch/:id/cashiers", a.authMiddleware, h.GetBranchCashiers) @@ -287,6 +289,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) From 6e6ed2c9a9fa0a2d8ad80b46461dc0ba745d7089 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Mon, 21 Jul 2025 04:31:38 +0300 Subject: [PATCH 09/10] fix: notifications for win bet and wallet balance low --- .vscode/settings.json | 9 +- cmd/main.go | 13 +- db/migrations/000007_setting_data.up.sql | 3 +- db/query/settings.sql | 24 +- db/query/user.sql | 7 +- gen/db/settings.sql.go | 12 - gen/db/user.sql.go | 31 + internal/domain/otp.go | 5 - internal/domain/settings.go | 33 +- internal/domain/sms.go | 18 + internal/repository/notification.go | 32 - internal/repository/settings.go | 56 ++ internal/repository/user.go | 21 + internal/repository/wallet.go | 34 + internal/services/bet/service.go | 241 ++++++- internal/services/messenger/email.go | 26 + internal/services/messenger/service.go | 21 + internal/services/messenger/sms.go | 85 +++ internal/services/notfication/service.go | 475 ------------- .../{notfication => notification}/port.go | 2 - internal/services/notification/service.go | 646 ++++++++++++++++++ internal/services/result/service.go | 2 +- internal/services/ticket/service.go | 2 +- internal/services/user/common.go | 84 +-- internal/services/user/direct.go | 7 +- internal/services/user/port.go | 1 + internal/services/user/register.go | 2 +- internal/services/user/reset.go | 2 +- internal/services/user/service.go | 16 +- internal/services/virtualGame/service.go | 2 +- internal/services/virtualGame/veli/service.go | 2 +- internal/services/wallet/monitor/service.go | 2 +- internal/services/wallet/port.go | 4 +- internal/services/wallet/service.go | 18 +- internal/services/wallet/wallet.go | 170 +++-- internal/web_server/app.go | 2 +- internal/web_server/handlers/handlers.go | 28 +- 37 files changed, 1414 insertions(+), 724 deletions(-) create mode 100644 internal/domain/sms.go create mode 100644 internal/services/messenger/email.go create mode 100644 internal/services/messenger/service.go create mode 100644 internal/services/messenger/sms.go delete mode 100644 internal/services/notfication/service.go rename internal/services/{notfication => notification}/port.go (87%) create mode 100644 internal/services/notification/service.go 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 9b8d3fc..70d99e7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -39,7 +39,8 @@ 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" + "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" @@ -101,13 +102,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 @@ -118,6 +121,8 @@ func main() { notificatioStore, // userStore, notificationSvc, + userSvc, + domain.MongoDBLogger, logger, ) @@ -125,7 +130,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/000007_setting_data.up.sql b/db/migrations/000007_setting_data.up.sql index f69156b..93f07ba 100644 --- a/db/migrations/000007_setting_data.up.sql +++ b/db/migrations/000007_setting_data.up.sql @@ -1,6 +1,7 @@ -- Settings Initial Data INSERT INTO settings (key, value) -VALUES ('max_number_of_outcomes', '30'), +VALUES ('sms_provider', '30'), +('max_number_of_outcomes', '30'), ('bet_amount_limit', '100000'), ('daily_ticket_limit', '50'), ('total_winnings_limit', '1000000'), 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/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/internal/domain/otp.go b/internal/domain/otp.go index 8eb4106..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 = "afro_message" -) 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/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 4aa764e..6aa8d4d 100644 --- a/internal/repository/wallet.go +++ b/internal/repository/wallet.go @@ -185,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 diff --git a/internal/services/bet/service.go b/internal/services/bet/service.go index 1d34bc0..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 } @@ -490,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, @@ -519,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", @@ -538,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", @@ -553,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", @@ -894,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) } @@ -914,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{}, @@ -935,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 { 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/service.go b/internal/services/notfication/service.go deleted file mode 100644 index f3ca2d6..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 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) 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) - 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.notificationStore.GetCompanyByWalletID(ctx, walletID) -} - -func (s *Service) GetBranchByWalletID(ctx context.Context, walletID int64) (domain.Branch, error) { - return s.notificationStore.GetBranchByWalletID(ctx, walletID) -} 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 683f39f..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 domain.TwilioSms: - if err := s.SendTwilioSMSOTP(ctx, sentTo, message, provider); err != nil { + if err := s.messengerSvc.SendTwilioSMS(ctx, sentTo, message); err != nil { return err } case domain.AfroMessage: - if err := s.SendAfroMessageSMSOTP(ctx, sentTo, message, provider); err != nil { + 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 773918d..76f2a5f 100644 --- a/internal/services/wallet/service.go +++ b/internal/services/wallet/service.go @@ -3,7 +3,9 @@ package wallet import ( "log/slog" - 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" ) type Service struct { @@ -12,17 +14,29 @@ type Service struct { transferStore TransferStore notificationStore notificationservice.NotificationStore notificationSvc *notificationservice.Service + userSvc *user.Service + mongoLogger *zap.Logger logger *slog.Logger // userStore user.UserStore } -func NewService(walletStore WalletStore, transferStore TransferStore, notificationStore notificationservice.NotificationStore, notificationSvc *notificationservice.Service, logger *slog.Logger) *Service { +func NewService( + walletStore WalletStore, + transferStore TransferStore, + notificationStore notificationservice.NotificationStore, + notificationSvc *notificationservice.Service, + userSvc *user.Service, + mongoLogger *zap.Logger, + logger *slog.Logger, +) *Service { return &Service{ walletStore: walletStore, transferStore: transferStore, // approvalStore: approvalStore, notificationStore: notificationStore, notificationSvc: notificationSvc, + userSvc: userSvc, + mongoLogger: mongoLogger, logger: logger, // userStore: userStore, // userStore users diff --git a/internal/services/wallet/wallet.go b/internal/services/wallet/wallet.go index 66f8bad..78ef385 100644 --- a/internal/services/wallet/wallet.go +++ b/internal/services/wallet/wallet.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "go.uber.org/zap" ) var ( @@ -59,6 +61,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) } @@ -76,12 +86,12 @@ func (s *Service) UpdateBalance(ctx context.Context, id int64, balance domain.Cu return err } - wallet, err := s.GetWalletByID(ctx, id) + _, err = s.GetWalletByID(ctx, id) if err != nil { return err } - go s.notificationSvc.UpdateLiveWalletMetricForWallet(ctx, wallet) + // go s.notificationSvc.UpdateLiveWalletMetricForWallet(ctx, wallet) return nil } @@ -117,7 +127,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 @@ -125,8 +135,10 @@ 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 { + if wallet.Type == domain.CompanyWalletType || wallet.Type == domain.BranchWalletType { s.SendAdminWalletInsufficientNotification(ctx, wallet, amount) + } else { + s.SendCustomerWalletInsufficientNotification(ctx, wallet, amount) } return domain.Transfer{}, ErrBalanceInsufficient } @@ -215,6 +227,55 @@ func (s *Service) UpdateWalletActive(ctx context.Context, id int64, isActive boo return s.walletStore.UpdateWalletActive(ctx, id, isActive) } +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{ @@ -222,7 +283,7 @@ func (s *Service) SendAdminWalletLowNotification(ctx context.Context, adminWalle Type: domain.NOTIFICATION_TYPE_ADMIN_ALERT, 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( @@ -240,35 +301,48 @@ func (s *Service) SendAdminWalletLowNotification(ctx context.Context, adminWalle } // 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.DeliveryChannelEmail, // Or any preferred admin channel + DeliveryChannel: domain.DeliveryChannelInApp, // Or any preferred admin channel Payload: domain.NotificationPayload{ Headline: "CREDIT Error: Admin Wallet insufficient to process customer request", Message: fmt.Sprintf( @@ -288,33 +362,49 @@ func (s *Service) SendAdminWalletInsufficientNotification(ctx context.Context, a } // Get admin recipients and send to all - adminRecipients, err := s.notificationStore.ListRecipientIDs(ctx, domain.NotificationRecieverSideAdmin) + + recipients, 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 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 - adminNotification := &domain.Notification{ + customerNotification := &domain.Notification{ RecipientID: customerWallet.UserID, Type: domain.NOTIFICATION_TYPE_WALLET, Level: domain.NotificationLevelError, - Reciever: domain.NotificationRecieverSideAdmin, - DeliveryChannel: domain.DeliveryChannelEmail, // Or any preferred admin channel + Reciever: domain.NotificationRecieverSideCustomer, + DeliveryChannel: domain.DeliveryChannelInApp, // Or any preferred admin channel Payload: domain.NotificationPayload{ - Headline: "CREDIT Error: Admin Wallet insufficient to process customer request", + Headline: "CREDIT Error: Wallet insufficient", Message: fmt.Sprintf( "Wallet ID %d. Transaction Amount %.2f. Current balance: %.2f", customerWallet.ID, @@ -331,10 +421,14 @@ func (s *Service) SendCustomerWalletInsufficientNotification(ctx context.Context }`, customerWallet.ID, customerWallet.Balance, amount.Float32()), } - if err := s.notificationStore.SendNotification(ctx, adminNotification); err != nil { - s.logger.Error("failed to send customer notification", - "admin_id", customerWallet.UserID, - "error", err) + 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 a9dd1b2..993d8ee 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -33,7 +33,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/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 6fc1e6b..0c5ac1d 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -15,7 +15,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" @@ -58,13 +58,13 @@ type Handler struct { virtualGameSvc virtualgameservice.VirtualGameService aleaVirtualGameSvc alea.AleaVirtualGameService veliVirtualGameSvc veli.VeliVirtualGameService - recommendationSvc recommendation.RecommendationService - authSvc *authentication.Service - resultSvc result.Service - jwtConfig jwtutil.JwtConfig - validator *customvalidator.CustomValidator - Cfg *config.Config - mongoLoggerSvc *zap.Logger + recommendationSvc recommendation.RecommendationService + authSvc *authentication.Service + resultSvc result.Service + jwtConfig jwtutil.JwtConfig + validator *customvalidator.CustomValidator + Cfg *config.Config + mongoLoggerSvc *zap.Logger } func New( @@ -124,11 +124,11 @@ func New( virtualGameSvc: virtualGameSvc, aleaVirtualGameSvc: aleaVirtualGameSvc, veliVirtualGameSvc: veliVirtualGameSvc, - recommendationSvc: recommendationSvc, - authSvc: authSvc, - resultSvc: resultSvc, - jwtConfig: jwtConfig, - Cfg: cfg, - mongoLoggerSvc: mongoLoggerSvc, + recommendationSvc: recommendationSvc, + authSvc: authSvc, + resultSvc: resultSvc, + jwtConfig: jwtConfig, + Cfg: cfg, + mongoLoggerSvc: mongoLoggerSvc, } } From d43b12c589d32e4b6147cfb54a3b939c476bae6f Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Tue, 22 Jul 2025 17:39:53 +0300 Subject: [PATCH 10/10] integration issues --- db/query/leagues.sql | 2 ++ gen/db/leagues.sql.go | 2 ++ internal/domain/company.go | 1 + internal/domain/issue_reporting.go | 2 +- internal/repository/bet.go | 20 ++++++------ internal/repository/league.go | 2 +- internal/web_server/cron.go | 32 +++++++++---------- .../web_server/handlers/branch_handler.go | 14 ++++---- .../web_server/handlers/company_handler.go | 8 +++-- .../web_server/handlers/issue_reporting.go | 18 ++++++++++- internal/web_server/handlers/leagues.go | 22 +++++++++++-- internal/web_server/handlers/mongoLogger.go | 4 +-- .../handlers/notification_handler.go | 2 +- internal/web_server/handlers/report.go | 7 ++-- internal/web_server/routes.go | 6 ++-- 15 files changed, 92 insertions(+), 50 deletions(-) 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/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/internal/domain/company.go b/internal/domain/company.go index 406f0fe..ec170be 100644 --- a/internal/domain/company.go +++ b/internal/domain/company.go @@ -112,6 +112,7 @@ func ConvertGetCompany(company GetCompany) GetCompanyRes { AdminFirstName: company.AdminFirstName, AdminLastName: company.AdminLastName, AdminPhoneNumber: company.AdminPhoneNumber, + } } 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/repository/bet.go b/internal/repository/bet.go index 2dee7b1..b9391f1 100644 --- a/internal/repository/bet.go +++ b/internal/repository/bet.go @@ -544,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/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/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/branch_handler.go b/internal/web_server/handlers/branch_handler.go index d085a38..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,7 +620,6 @@ 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 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/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 a34b8e5..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,10 +72,9 @@ 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 - role := c.Locals("role").(domain.Role) if c.Query("company_id") != "" && role == domain.RoleSuperAdmin { diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index acb6a45..4b37043 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -55,7 +55,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", }) }) // Swagger @@ -65,7 +65,7 @@ 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 @@ -241,7 +241,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)