package participation_service import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "html" "log" "net/url" "strings" "time" // Ensure timezone data is available in minimal runtime images. _ "time/tzdata" "evening_detective_server/internal/modules/email_sender" "evening_detective_server/internal/repos/teams_repo" "github.com/jackc/pgx/v5" ) type ParticipationService struct { teamsRepo *teams_repo.TeamsRepo appBaseURL string } func NewParticipationService(teamsRepo *teams_repo.TeamsRepo, appBaseURL string) *ParticipationService { return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL} } func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*Participation, error) { p, err := s.teamsRepo.GetParticipation(ctx, token) return mapParticipation(p), mapParticipationError(err) } func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) { p, err := s.teamsRepo.AnswerParticipation(ctx, token, answer) return mapParticipation(p), mapParticipationError(err) } func mapParticipationError(err error) error { switch { case errors.Is(err, teams_repo.ErrParticipationLink): return ErrParticipationLink case errors.Is(err, teams_repo.ErrParticipationClosed): return ErrParticipationClosed case errors.Is(err, teams_repo.ErrParticipationFinal): return ErrParticipationFinal case err != nil && err.Error() == ErrInvalidAnswer.Error(): return ErrInvalidAnswer default: return err } } // Стабильные токены для разных целей сохраняют работоспособность ссылок при // повторе после неоднозначной ошибки SMTP. // В базе хранится только хеш; секрет подписи должен быть постоянным между // рабочими процессами и перезапусками. 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 *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) content := "
Подтвердите участие в игре
" + 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", "Отменить участие"}} { content += email_sender.ButtonHTML(link+"&answer="+action.value, action.label) } content += "Ответ можно дать до начала игры. Решение окончательное: изменить его нельзя.
Без ответа запись сохраняется со статусом «Ожидает ответа».
Не пересылайте письмо: ссылка позволяет принять решение за команду.
" markup := email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/")) return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup} } func (s *ParticipationService) 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 *ParticipationService) 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) repoParticipation, err := s.teamsRepo.ClaimParticipationEmail(jobCtx) if errors.Is(err, pgx.ErrNoRows) { cancel() return nil } if err != nil { cancel() return err } p := mapParticipation(repoParticipation) 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() // Сохраняем результат даже если отправка исчерпала время задачи. 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() }