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 } } // Cap winners to a safe maximum if nWinners > 100 { nWinners = 100 } 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 } // Localize end date to Czech format in Europe/Prague timezone loc, _ := time.LoadLocation("Europe/Prague") endsLocal := cur.EndAt.In(loc) endsAtCz := endsLocal.Format("02. 01. 2006 15:04") _ = 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": endsAtCz, }, }) } // 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 }