Files
MyClub/internal/services/sweepstakes.go
T
Tomas Dvorak 087f30e82c dev day #80
2025-11-02 21:31:00 +01:00

198 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"log"
"math/rand"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/pkg/email"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SweepstakesService encapsulates business logic for sweepstakes
type SweepstakesService struct {
DB *gorm.DB
Email email.EmailService
}
func NewSweepstakesService(db *gorm.DB, es email.EmailService) *SweepstakesService {
return &SweepstakesService{DB: db, Email: es}
}
// StartSweepstakesScheduler periodically finalizes ended sweepstakes and picks winners
func StartSweepstakesScheduler(db *gorm.DB, es email.EmailService) {
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
if err := finalizeDueSweepstakes(db, es); err != nil {
log.Printf("[sweepstakes] finalize cycle error: %v", err)
}
<-ticker.C
}
}()
}
func finalizeDueSweepstakes(db *gorm.DB, es email.EmailService) error {
// Find sweepstakes that ended and have not selected winners yet
var list []models.Sweepstake
now := time.Now()
if err := db.Where("end_at <= ? AND (winners_selected_at IS NULL) AND status <> ?", now, "finalized").Find(&list).Error; err != nil {
return err
}
if len(list) == 0 {
return nil
}
svc := NewSweepstakesService(db, es)
for _, s := range list {
if err := svc.FinalizeSweepstake(&s, ""); err != nil {
log.Printf("[sweepstakes] finalize %d failed: %v", s.ID, err)
}
}
return nil
}
// FinalizeSweepstake selects winners deterministically (if DrawSeed exists) and updates records.
// If seed is empty and sweepstake.DrawSeed empty, a new seed is generated from time.
func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed string) error {
return s.DB.Transaction(func(tx *gorm.DB) error {
// Reload for update to avoid races
var cur models.Sweepstake
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&cur, sw.ID).Error; err != nil {
return err
}
if cur.WinnersSelectedAt != nil {
return nil // already finalized
}
// Load entries
var entries []models.SweepstakeEntry
if err := tx.Where("sweepstake_id = ? AND status = ?", cur.ID, "valid").Find(&entries).Error; err != nil {
return err
}
// Load prizes
var prizes []models.SweepstakePrize
if err := tx.Where("sweepstake_id = ?", cur.ID).Order("display_order ASC, id ASC").Find(&prizes).Error; err != nil {
return err
}
// Determine number of winners
nWinners := 0
for _, p := range prizes { nWinners += max(0, p.Quantity) }
if nWinners == 0 {
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
}
if nWinners > len(entries) { nWinners = len(entries) }
// Build seed
effSeed := strings.TrimSpace(seed)
if effSeed == "" { effSeed = strings.TrimSpace(cur.DrawSeed) }
if effSeed == "" { effSeed = fmt.Sprintf("%d-%d", cur.ID, time.Now().UnixNano()) }
// Deterministic RNG from SHA-256
h := sha256.Sum256([]byte(effSeed))
base := binary.LittleEndian.Uint64(h[:8])
rng := rand.New(rand.NewSource(int64(base)))
// Shuffle indices
idx := rng.Perm(len(entries))
picked := make(map[uint]bool)
winners := make([]models.SweepstakeWinner, 0, nWinners)
assign := func(userID uint, entryID uint, prize *models.SweepstakePrize) {
w := models.SweepstakeWinner{
SweepstakeID: cur.ID,
EntryID: entryID,
UserID: userID,
ClaimStatus: "pending",
}
if prize != nil {
w.PrizeID = &prize.ID
w.PrizeName = prize.Name
}
winners = append(winners, w)
}
pos := 0
// Assign by prizes first
for i := range prizes {
q := max(0, prizes[i].Quantity)
for j := 0; j < q && pos < len(idx); j++ {
cand := entries[idx[pos]]
pos++
if picked[cand.UserID] { j--; continue }
picked[cand.UserID] = true
assign(cand.UserID, cand.ID, &prizes[i])
if len(winners) >= nWinners { break }
}
if len(winners) >= nWinners { break }
}
// If still need more (when TotalPrizes used)
for len(winners) < nWinners && pos < len(idx) {
cand := entries[idx[pos]]
pos++
if picked[cand.UserID] { continue }
picked[cand.UserID] = true
assign(cand.UserID, cand.ID, nil)
}
// Persist winners
for i := range winners {
if err := tx.Create(&winners[i]).Error; err != nil {
return err
}
}
now := time.Now()
vis := cur.EndAt.Add(72 * time.Hour)
if err := tx.Model(&models.Sweepstake{}).Where("id = ?", cur.ID).Updates(map[string]interface{}{
"winners_selected_at": now,
"visibility_until": vis,
"draw_seed": effSeed,
"status": "finalized",
}).Error; err != nil {
return err
}
// Send emails (best-effort)
if s.Email != nil && len(winners) > 0 {
for _, w := range winners {
var user models.User
_ = tx.First(&user, w.UserID).Error
if strings.TrimSpace(user.Email) == "" { continue }
_ = s.Email.SendEmail(&email.EmailData{
Subject: "Vyhráli jste v soutěži!",
To: []string{strings.TrimSpace(user.Email)},
Template: "sweepstake_winner_user",
Data: map[string]interface{}{
"Title": cur.Title,
"PrizeName": w.PrizeName,
"EndsAt": cur.EndAt.Format(time.RFC1123),
},
})
}
// Admin summary
var set models.Settings
_ = tx.First(&set).Error
adminTo := strings.TrimSpace(set.ContactEmail)
if adminTo == "" { adminTo = strings.TrimSpace(set.SMTPFrom) }
if adminTo != "" {
_ = s.Email.SendEmail(&email.EmailData{
Subject: "Soutěž vybraní výherci",
To: []string{adminTo},
Template: "sweepstake_winner_admin",
Data: map[string]interface{}{
"Title": cur.Title,
"WinnersCount": len(winners),
},
})
}
}
return nil
})
}
func max(a, b int) int { if a > b { return a } ; return b }