package game_service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "html" "log" "net/url" "strings" "time" _ "time/tzdata" "evening_detective_server/internal/modules/email_sender" "evening_detective_server/internal/repos/teams_repo" "github.com/jackc/pgx/v5" ) func (s *GameService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) { return s.teamsRepo.GetParticipation(ctx, token) } func (s *GameService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) { return s.teamsRepo.AnswerParticipation(ctx, token, answer) } // Stable, purpose-separated tokens keep links valid when an ambiguous SMTP failure is retried. // Only the hash is stored in the database; the signing secret must stay stable across workers/restarts. func participationToken(secret string, id int) string { mac := hmac.New(sha256.New, []byte(secret)) fmt.Fprintf(mac, "team-participation:v1:%d", id) return hex.EncodeToString(mac.Sum(nil)) } func participationEmail(p *teams_repo.Participation, baseURL, token string, location *time.Location) email_sender.Message { link := strings.TrimRight(baseURL, "/") + "/participation#token=" + token date := p.StartAt.In(location).Format("02.01.2006 в 15:04 (MST, UTC-07:00)") body := fmt.Sprintf("Команда «%s», подтвердите участие в игре «%s».\nДата: %s\nУчастников: %d\n\nПодтвердить участие: %s&answer=confirmed\nОтменить участие: %s&answer=cancelled\n\nОтвет можно дать до начала игры. Решение окончательное: изменить его нельзя. Без ответа запись сохраняется со статусом «Ожидает ответа».\nНе пересылайте письмо: ссылка позволяет принять решение за команду.", p.TeamName, p.GameName, date, p.ParticipantsCount, link, link) markup := "
" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»
" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d
", p.ParticipantsCount) for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} { markup += `` } markup += "Ответ можно дать до начала игры. Решение окончательное: изменить его нельзя.
Без ответа запись сохраняется со статусом «Ожидает ответа».
Не пересылайте письмо: ссылка позволяет принять решение за команду.
" return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup} } func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) { base, err := url.Parse(s.appBaseURL) if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" || secret == "" { log.Print("participation email worker disabled: configure APP_BASE_URL and PARTICIPATION_SECRET") return } if timezone == "" { timezone = "Asia/Barnaul" } location, err := time.LoadLocation(timezone) if err != nil { log.Printf("participation email worker disabled: invalid GAME_TIMEZONE: %v", err) return } ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { if err := s.sendParticipationEmails(ctx, sender, secret, location); err != nil && ctx.Err() == nil { log.Printf("participation email worker: %v", err) } select { case <-ctx.Done(): return case <-ticker.C: } } } func (s *GameService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error { for ctx.Err() == nil { jobCtx, cancel := context.WithTimeout(ctx, time.Minute) p, err := s.teamsRepo.ClaimParticipationEmail(jobCtx) if errors.Is(err, pgx.ErrNoRows) { cancel() return nil } if err != nil { cancel() return err } token := participationToken(secret, p.TeamID) err = s.teamsRepo.SetParticipationToken(jobCtx, p.TeamID, token) if err == nil { err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location)) } cancel() // Persist even when sending consumed the job's deadline. saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second) saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err) saveCancel() if saveErr != nil { return saveErr } } return ctx.Err() }