diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 966e2b2..75f2ed2 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -515,7 +515,13 @@ CREATE TABLE IF NOT EXISTS raffle_tickets ( raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE, user_id INT NOT NULL, is_active BOOL DEFAULT true, - UNIQUE (raffle_id, user_id) +); +CREATE TABLE IF NOT EXISTS raffle_winners ( + id SERIAL PRIMARY KEY, + raffle_id INT NOT NULL REFERENCES raffles(id) ON DELETE CASCADE, + user_id INT NOT NULL, + rank INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() ); ------ Views CREATE VIEW companies_details AS @@ -731,4 +737,4 @@ ADD CONSTRAINT fk_event_settings_company FOREIGN KEY (company_id) REFERENCES com ADD CONSTRAINT fk_event_settings_event FOREIGN KEY (event_id) REFERENCES events (id) ON DELETE CASCADE; ALTER TABLE company_odd_settings ADD CONSTRAINT fk_odds_settings_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE, - ADD CONSTRAINT fk_odds_settings_odds_market FOREIGN KEY (odds_market_id) REFERENCES odds_market (id) ON DELETE CASCADE; \ No newline at end of file + ADD CONSTRAINT fk_odds_settings_odds_market FOREIGN KEY (odds_market_id) REFERENCES odds_market (id) ON DELETE CASCADE; diff --git a/db/query/raffle.sql b/db/query/raffle.sql index 4c43a10..089022f 100644 --- a/db/query/raffle.sql +++ b/db/query/raffle.sql @@ -32,3 +32,30 @@ SELECT FROM raffle_tickets rt JOIN raffles r ON rt.raffle_id = r.id WHERE rt.user_id = $1; + +-- name: GetRaffleStanding :many +SELECT + u.id AS user_id, + rt.raffle_id, + u.first_name, + u.last_name, + u.phone_number, + u.email, + COUNT(*) AS ticket_count +FROM raffle_tickets rt +JOIN users u ON rt.user_id = u.id +WHERE rt.is_active = true + AND rt.raffle_id = $1 +GROUP BY u.id, rt.raffle_id, u.first_name, u.last_name, u.phone_number, u.email +ORDER BY ticket_count DESC +LIMIT $2; + +-- name: CreateRaffleWinner :one +INSERT INTO raffle_winners (raffle_id, user_id, rank) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: SetRaffleComplete :exec +UPDATE raffles +SET status = 'completed' +WHERE id = $1; diff --git a/gen/db/models.go b/gen/db/models.go index b8bbdc9..863dc14 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -514,6 +514,14 @@ type ReferralCode struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type RaffleWinner struct { + ID int32 `json:"id"` + RaffleID int32 `json:"raffle_id"` + UserID int32 `json:"user_id"` + Rank int32 `json:"rank"` + CreatedAt pgtype.Timestamp `json:"created_at"` +} + type RefreshToken struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` diff --git a/gen/db/raffle.sql.go b/gen/db/raffle.sql.go index b5705fb..7d0bab1 100644 --- a/gen/db/raffle.sql.go +++ b/gen/db/raffle.sql.go @@ -67,6 +67,31 @@ func (q *Queries) CreateRaffleTicket(ctx context.Context, arg CreateRaffleTicket return i, err } +const CreateRaffleWinner = `-- name: CreateRaffleWinner :one +INSERT INTO raffle_winners (raffle_id, user_id, rank) +VALUES ($1, $2, $3) +RETURNING id, raffle_id, user_id, rank, created_at +` + +type CreateRaffleWinnerParams struct { + RaffleID int32 `json:"raffle_id"` + UserID int32 `json:"user_id"` + Rank int32 `json:"rank"` +} + +func (q *Queries) CreateRaffleWinner(ctx context.Context, arg CreateRaffleWinnerParams) (RaffleWinner, error) { + row := q.db.QueryRow(ctx, CreateRaffleWinner, arg.RaffleID, arg.UserID, arg.Rank) + var i RaffleWinner + err := row.Scan( + &i.ID, + &i.RaffleID, + &i.UserID, + &i.Rank, + &i.CreatedAt, + ) + return i, err +} + const DeleteRaffle = `-- name: DeleteRaffle :one DELETE FROM raffles WHERE id = $1 @@ -88,6 +113,67 @@ func (q *Queries) DeleteRaffle(ctx context.Context, id int32) (Raffle, error) { return i, err } +const GetRaffleStanding = `-- name: GetRaffleStanding :many +SELECT + u.id AS user_id, + rt.raffle_id, + u.first_name, + u.last_name, + u.phone_number, + u.email, + COUNT(*) AS ticket_count +FROM raffle_tickets rt +JOIN users u ON rt.user_id = u.id +WHERE rt.is_active = true + AND rt.raffle_id = $1 +GROUP BY u.id, rt.raffle_id, u.first_name, u.last_name, u.phone_number, u.email +ORDER BY ticket_count DESC +LIMIT $2 +` + +type GetRaffleStandingParams struct { + RaffleID int32 `json:"raffle_id"` + Limit int32 `json:"limit"` +} + +type GetRaffleStandingRow struct { + UserID int64 `json:"user_id"` + RaffleID int32 `json:"raffle_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber pgtype.Text `json:"phone_number"` + Email pgtype.Text `json:"email"` + TicketCount int64 `json:"ticket_count"` +} + +func (q *Queries) GetRaffleStanding(ctx context.Context, arg GetRaffleStandingParams) ([]GetRaffleStandingRow, error) { + rows, err := q.db.Query(ctx, GetRaffleStanding, arg.RaffleID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetRaffleStandingRow + for rows.Next() { + var i GetRaffleStandingRow + if err := rows.Scan( + &i.UserID, + &i.RaffleID, + &i.FirstName, + &i.LastName, + &i.PhoneNumber, + &i.Email, + &i.TicketCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetRafflesOfCompany = `-- name: GetRafflesOfCompany :many SELECT id, company_id, name, created_at, expires_at, type, status FROM raffles WHERE company_id = $1 ` @@ -169,6 +255,17 @@ func (q *Queries) GetUserRaffleTickets(ctx context.Context, userID int32) ([]Get return items, nil } +const SetRaffleComplete = `-- name: SetRaffleComplete :exec +UPDATE raffles +SET status = 'completed' +WHERE id = $1 +` + +func (q *Queries) SetRaffleComplete(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, SetRaffleComplete, id) + return err +} + const UpdateRaffleTicketStatus = `-- name: UpdateRaffleTicketStatus :exec UPDATE raffle_tickets SET is_active = $1 diff --git a/internal/domain/raffle.go b/internal/domain/raffle.go index c6adc2c..7366b0d 100644 --- a/internal/domain/raffle.go +++ b/internal/domain/raffle.go @@ -12,6 +12,22 @@ type Raffle struct { Status string } +type RaffleStanding struct { + UserID int64 + RaffleID int32 + FirstName string + LastName string + PhoneNumber string + Email string + TicketCount int64 +} + +type RaffleWinnerParams struct { + RaffleID int32 + UserID int32 + Rank int32 +} + type RaffleTicket struct { ID int32 RaffleID int32 diff --git a/internal/pkgs/helpers/helpers.go b/internal/pkgs/helpers/helpers.go index f336b1b..cb4a4ec 100644 --- a/internal/pkgs/helpers/helpers.go +++ b/internal/pkgs/helpers/helpers.go @@ -3,6 +3,8 @@ package helpers import ( random "crypto/rand" "fmt" + "strings" + "github.com/google/uuid" "math/big" "math/rand/v2" @@ -42,3 +44,17 @@ func GenerateCashoutID() (string, error) { return string(result), nil } + +func MaskPhone(phone string) string { + if phone == "" { + return "" + } + return phone[:4] + "**" + phone[len(phone)-2:] +} + +func MaskEmail(email string) string { + if email == "" { + return "" + } + return email[:3] + "**" + email[strings.Index(email, "@"):] +} diff --git a/internal/repository/raffel.go b/internal/repository/raffel.go index db230bd..f458c2c 100644 --- a/internal/repository/raffel.go +++ b/internal/repository/raffel.go @@ -52,6 +52,18 @@ func convertCreateRaffle(raffle domain.CreateRaffle) dbgen.CreateRaffleParams { } } +func convertRaffleStanding(raffleStanding dbgen.GetRaffleStandingRow) domain.RaffleStanding { + return domain.RaffleStanding{ + UserID: raffleStanding.UserID, + RaffleID: raffleStanding.RaffleID, + FirstName: raffleStanding.FirstName, + LastName: raffleStanding.LastName, + PhoneNumber: raffleStanding.PhoneNumber.String, + Email: raffleStanding.Email.String, + TicketCount: raffleStanding.TicketCount, + } +} + func (s *Store) CreateRaffle(ctx context.Context, raffle domain.CreateRaffle) (domain.Raffle, error) { raffleRes, err := s.queries.CreateRaffle(ctx, convertCreateRaffle(raffle)) if err != nil { @@ -126,3 +138,34 @@ func (s *Store) UnSuspendRaffleTicket(ctx context.Context, raffleID int32) error } // TODO: could also add -> suspend a specific user's raffle tickets + +func (s *Store) GetRaffleStanding(ctx context.Context, raffleID, limit int32) ([]domain.RaffleStanding, error) { + raffleStanding, err := s.queries.GetRaffleStanding(ctx, dbgen.GetRaffleStandingParams{ + RaffleID: raffleID, + Limit: limit, + }) + if err != nil { + return nil, err + } + + res := []domain.RaffleStanding{} + for _, standing := range raffleStanding { + res = append(res, convertRaffleStanding(standing)) + } + + return res, nil +} + +func (s *Store) CreateRaffleWinner(ctx context.Context, raffleWinnerParams domain.RaffleWinnerParams) error { + _, err := s.queries.CreateRaffleWinner(ctx, dbgen.CreateRaffleWinnerParams{ + RaffleID: raffleWinnerParams.RaffleID, + UserID: raffleWinnerParams.UserID, + Rank: raffleWinnerParams.Rank, + }) + + return err +} + +func (s *Store) SetRaffleComplete(ctx context.Context, raffleID int32) error { + return s.queries.SetRaffleComplete(ctx, raffleID) +} diff --git a/internal/services/raffle/port.go b/internal/services/raffle/port.go index c8c4b8e..ee41045 100644 --- a/internal/services/raffle/port.go +++ b/internal/services/raffle/port.go @@ -11,6 +11,9 @@ type RaffleStore interface { CreateRaffle(ctx context.Context, raffle domain.CreateRaffle) (domain.Raffle, error) DeleteRaffle(ctx context.Context, raffleID int32) (domain.Raffle, error) GetRafflesOfCompany(ctx context.Context, companyID int32) ([]dbgen.Raffle, error) + GetRaffleStanding(ctx context.Context, raffleID, limit int32) ([]domain.RaffleStanding, error) + CreateRaffleWinner(ctx context.Context, raffleWinnerParams domain.RaffleWinnerParams) error + SetRaffleComplete(ctx context.Context, raffleID int32) error CreateRaffleTicket(ctx context.Context, raffleTicketParams domain.CreateRaffleTicket) (domain.RaffleTicket, error) GetUserRaffleTickets(ctx context.Context, userID int32) ([]domain.RaffleTicketRes, error) SuspendRaffleTicket(ctx context.Context, raffleTicketID int32) error diff --git a/internal/services/raffle/service.go b/internal/services/raffle/service.go index 1246fb7..3ca6b0e 100644 --- a/internal/services/raffle/service.go +++ b/internal/services/raffle/service.go @@ -29,6 +29,18 @@ func (s *Service) GetRafflesOfCompany(ctx context.Context, companyID int32) ([]d return s.raffleStore.GetRafflesOfCompany(ctx, companyID) } +func (s *Service) GetRaffleStanding(ctx context.Context, raffleID, limit int32) ([]domain.RaffleStanding, error) { + return s.raffleStore.GetRaffleStanding(ctx, raffleID, limit) +} + +func (s *Service) CreateRaffleWinner(ctx context.Context, raffleWinnerParams domain.RaffleWinnerParams) error { + return s.raffleStore.CreateRaffleWinner(ctx, raffleWinnerParams) +} + +func (s *Service) SetRaffleComplete(ctx context.Context, raffleID int32) error { + return s.raffleStore.SetRaffleComplete(ctx, raffleID) +} + func (s *Service) CreateRaffleTicket(ctx context.Context, raffleTicketParams domain.CreateRaffleTicket) (domain.RaffleTicket, error) { return s.raffleStore.CreateRaffleTicket(ctx, raffleTicketParams) } diff --git a/internal/web_server/handlers/raffle_handler.go b/internal/web_server/handlers/raffle_handler.go index d1b59ba..6f64a9b 100644 --- a/internal/web_server/handlers/raffle_handler.go +++ b/internal/web_server/handlers/raffle_handler.go @@ -1,11 +1,13 @@ package handlers import ( + "errors" "fmt" "strconv" "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/pkgs/helpers" "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" "github.com/gofiber/fiber/v2" "go.uber.org/zap" @@ -101,9 +103,102 @@ func (h *Handler) GetRafflesOfCompany(c *fiber.Ctx) error { return response.WriteJSON(c, fiber.StatusOK, "Company Raffles fetched successfully", companyRaffles, nil) } +func (h *Handler) GetRaffleStanding(c *fiber.Ctx) error { + raffleIDStr := c.Params("id") + limitStr := c.Params("limit") + + // if error happens while parsing, it just uses zero values + // resulting in empty standing + raffleID, _ := strconv.Atoi(raffleIDStr) + limit, _ := strconv.Atoi(limitStr) + + raffleStanding, err := h.raffleSvc.GetRaffleStanding(c.Context(), int32(raffleID), int32(limit)) + if err != nil { + h.mongoLoggerSvc.Error("Failed to fetch raffle standing", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch raffle standing") + } + + maskedRaffleStanding := []domain.RaffleStanding{} + for _, standing := range raffleStanding { + maskedStanding := domain.RaffleStanding{ + UserID: standing.UserID, + RaffleID: standing.RaffleID, + FirstName: standing.FirstName, + LastName: standing.LastName, + PhoneNumber: helpers.MaskPhone(standing.PhoneNumber), + Email: helpers.MaskEmail(standing.Email), + TicketCount: standing.TicketCount, + } + + maskedRaffleStanding = append(maskedRaffleStanding, maskedStanding) + } + + return response.WriteJSON(c, fiber.StatusOK, "Raffles standing fetched successfully", maskedRaffleStanding, nil) +} + +func (h *Handler) GetRaffleWinners(c *fiber.Ctx) error { + raffleIDStr := c.Params("id") + limitStr := c.Params("limit") + + // if error happens while parsing, it just uses zero values + // resulting in empty standing + raffleID, _ := strconv.Atoi(raffleIDStr) + limit, _ := strconv.Atoi(limitStr) + + raffleStanding, err := h.raffleSvc.GetRaffleStanding(c.Context(), int32(raffleID), int32(limit)) + if err != nil { + h.mongoLoggerSvc.Error("Failed to fetch raffle standing", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch raffle standing") + } + + // set raffle as complete + if err := h.raffleSvc.SetRaffleComplete(c.Context(), int32(raffleID)); err != nil { + h.mongoLoggerSvc.Error("Failed to set raffle complete", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(err), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to set raffle complete") + } + + // add winners to table + var errs []error + for i, standing := range raffleStanding { + err = h.raffleSvc.CreateRaffleWinner(c.Context(), domain.RaffleWinnerParams{ + RaffleID: standing.RaffleID, + UserID: int32(standing.UserID), + Rank: int32(i + 1), + }) + + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) != 0 { + h.mongoLoggerSvc.Error("Failed to create raffle winners", + zap.Int("status_code", fiber.StatusInternalServerError), + zap.Error(errors.Join(errs...)), + zap.Time("timestamp", time.Now()), + ) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create raffle winners") + } + + return nil +} + func (h *Handler) CreateRaffleTicket(c *fiber.Ctx) error { var req domain.CreateRaffleTicket if err := c.BodyParser(&req); err != nil { + fmt.Println("parser error: ", err) h.mongoLoggerSvc.Info("Failed to parse raffle ticket request", zap.Int("status_code", fiber.StatusBadRequest), zap.Error(err), @@ -127,6 +222,7 @@ func (h *Handler) CreateRaffleTicket(c *fiber.Ctx) error { raffleTicket, err := h.raffleSvc.CreateRaffleTicket(c.Context(), req) if err != nil { + fmt.Println("raffle ticket create error: ", err) h.mongoLoggerSvc.Error("Failed to create raffle ticket", zap.Int("status_code", fiber.StatusInternalServerError), zap.Error(err), diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 044a501..f94cf4b 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -201,6 +201,8 @@ func (a *App) initAppRoutes() { a.fiber.Post("/raffle/create", a.authMiddleware, h.CreateRaffle) a.fiber.Get("/raffle/delete/:id", a.authMiddleware, h.DeleteRaffle) a.fiber.Get("/raffle/company/:id", a.authMiddleware, h.GetRafflesOfCompany) + a.fiber.Get("/raffle/standing/:id/:limit", a.authMiddleware, h.GetRaffleStanding) + a.fiber.Get("raffle/winners/:id/:limit", a.authMiddleware, h.GetRaffleWinners) a.fiber.Post("/raffle-ticket/create", a.authMiddleware, h.CreateRaffleTicket) a.fiber.Get("/raffle-ticket/:id", a.authMiddleware, h.GetUserRaffleTickets) a.fiber.Get("/raffle-ticket/suspend/:id", a.authMiddleware, h.SuspendRaffleTicket)