generated from VLADIMIR/template
135 lines
6.3 KiB
Go
135 lines
6.3 KiB
Go
package participation_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"
|
|
)
|
|
|
|
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 := "<p class=\"email-text\"><strong>Подтвердите участие в игре</strong></p><p class=\"email-text\">" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»</p><p class=\"email-text\">" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d</p>", p.ParticipantsCount)
|
|
for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} {
|
|
content += email_sender.ButtonHTML(link+"&answer="+action.value, action.label)
|
|
}
|
|
content += "<p class=\"email-text\">Ответ можно дать до начала игры. <strong>Решение окончательное: изменить его нельзя.</strong></p><p class=\"email-text\">Без ответа запись сохраняется со статусом «Ожидает ответа».</p><p class=\"email-text\">Не пересылайте письмо: ссылка позволяет принять решение за команду.</p>"
|
|
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()
|
|
}
|