diff --git a/README.md b/README.md index c16a563..ff307e7 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ MCP-эндпоинт: `http://localhost:8090/api/mcp`. ВНИМАНИЕ: REST-ga `POST /api/games/{id}/registrations` — отдельный HTTP webhook, без пользовательского JWT. Капитан не должен иметь учётную запись. Создаётся обычная команда с автоматически -сгенерированным паролем; почта капитана и количество участников видны организатору +сгенерированным паролем; капитану отправляется письмо с данными регистрации, почта капитана и количество участников видны организатору в списке команд игры. Письма автоматически не отправляются. Перед запуском примените миграции и задайте `YANDEX_FORMS_TOKEN` в окружении сервера diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 54b2e2d..00a6f7b 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -148,7 +148,7 @@ func main() { answerService := answer_service.NewAnswerService(gameRepo, teamRepo, scenarioService, answersRepo, actionsRepo) - teamService := team_service.NewTeamService(gameRepo, teamRepo, passwordGenerator, os.Getenv("APP_BASE_URL")) + teamService := team_service.NewTeamService(gameRepo, teamRepo, passwordGenerator, os.Getenv("APP_BASE_URL"), emailSender) participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL")) go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) diff --git a/internal/modules/email_sender/template.go b/internal/modules/email_sender/template.go new file mode 100644 index 0000000..3932ba4 --- /dev/null +++ b/internal/modules/email_sender/template.go @@ -0,0 +1,44 @@ +package email_sender + +import "html" + +func ButtonHTML(link, label string) string { + return ` +
` +} + +// WrapHTML is the shared light/dark email layout used by all service emails. +func WrapHTML(content, baseURL string) string { + fontURL := "" + if baseURL != "" { + fontURL = html.EscapeString(baseURL + "/email/a_OldTyper.ttf") + } + return ` + + + + + +` +} diff --git a/internal/services/game_service/lifecycle_test.go b/internal/services/game_service/lifecycle_test.go index e7e02af..6ddccb9 100644 --- a/internal/services/game_service/lifecycle_test.go +++ b/internal/services/game_service/lifecycle_test.go @@ -68,7 +68,7 @@ func TestServicesLifecyclePostgres(t *testing.T) { textCleaner := cleaner.NewCleaner() scenarioService := scenarios_service.NewScenarioService(scenarios, textCleaner, "https://files.test/", nil, nil) game := game_service.NewGameService(games, teams, scenarios, scenarioService, "https://files.test/", actions, applications) - team := team_service.NewTeamService(games, teams, password_generator.NewGenerator(12), "https://app.test") + team := team_service.NewTeamService(games, teams, password_generator.NewGenerator(12), "https://app.test", nil) play := gameplay_service.NewGameplayService(games, teams, scenarioService, storytelling.NewStory(textCleaner), actions, applications, textCleaner, "https://files.test/") answers := answer_service.NewAnswerService(games, teams, scenarioService, team_answers_repo.NewTeamAnswersRepo(pool), actions) participation := participation_service.NewParticipationService(teams, "https://app.test") diff --git a/internal/services/participation_service/service.go b/internal/services/participation_service/service.go index 12d6fd6..2b29f24 100644 --- a/internal/services/participation_service/service.go +++ b/internal/services/participation_service/service.go @@ -47,11 +47,12 @@ func participationEmail(p *teams_repo.Participation, baseURL, token string, loca 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) + 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", "Отменить участие"}} { - markup += `

` + action.label + `

` + content += email_sender.ButtonHTML(link+"&answer="+action.value, action.label) } - markup += "

Ответ можно дать до начала игры. Решение окончательное: изменить его нельзя.

Без ответа запись сохраняется со статусом «Ожидает ответа».

Не пересылайте письмо: ссылка позволяет принять решение за команду.

" + content += "

Ответ можно дать до начала игры. Решение окончательное: изменить его нельзя.

Без ответа запись сохраняется со статусом «Ожидает ответа».

Не пересылайте письмо: ссылка позволяет принять решение за команду.

" + markup := email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/")) return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup} } diff --git a/internal/services/team_service/registration_test.go b/internal/services/team_service/registration_test.go index b09b690..add1f2f 100644 --- a/internal/services/team_service/registration_test.go +++ b/internal/services/team_service/registration_test.go @@ -3,6 +3,8 @@ package team_service import ( "strings" "testing" + + "evening_detective_server/internal/repos" ) func TestRegistrationValidation(t *testing.T) { @@ -30,3 +32,24 @@ func TestRegistrationValidation(t *testing.T) { } } } + +func TestRegistrationEmail(t *testing.T) { + message := registrationEmail(&repos.Team{ + ID: 7, + Name: "Команда", + CaptainEmail: "captain@example.org", + ParticipantsCount: 5, + Password: "abcdefgh", + }, "Большая игра", "https://app.test") + if message.To != "captain@example.org" { + t.Errorf("wrong recipient: %q", message.To) + } + for _, value := range []string{message.Body, message.HTML} { + if !strings.Contains(value, "Команда") || !strings.Contains(value, "abcdefgh") { + t.Errorf("registration email misses team data: %q", value) + } + } + if !strings.Contains(message.Body, "https://app.test/team-story/7?password=abcdefgh") { + t.Error("registration email misses team link") + } +} diff --git a/internal/services/team_service/service.go b/internal/services/team_service/service.go index 91f5c41..fae39cb 100644 --- a/internal/services/team_service/service.go +++ b/internal/services/team_service/service.go @@ -3,13 +3,18 @@ package team_service import ( "context" "errors" + "fmt" + "html" + "strings" + "time" + + "evening_detective_server/internal/modules/email_sender" "evening_detective_server/internal/modules/game_model" "evening_detective_server/internal/modules/password_generator" "evening_detective_server/internal/modules/team_qr_pdf" + "evening_detective_server/internal/repos" "evening_detective_server/internal/repos/games_repo" "evening_detective_server/internal/repos/teams_repo" - "strings" - "time" ) type TeamService struct { @@ -17,6 +22,7 @@ type TeamService struct { teamsRepo *teams_repo.TeamsRepo passwordGenerator password_generator.IPasswordGenerator appBaseURL string + emailSender email_sender.IEmailSender } func NewTeamService( @@ -24,12 +30,14 @@ func NewTeamService( teamsRepo *teams_repo.TeamsRepo, passwordGenerator password_generator.IPasswordGenerator, appBaseURL string, + emailSender email_sender.IEmailSender, ) *TeamService { return &TeamService{ gamesRepo: gamesRepo, teamsRepo: teamsRepo, passwordGenerator: passwordGenerator, appBaseURL: appBaseURL, + emailSender: emailSender, } } @@ -86,7 +94,29 @@ func (s *TeamService) RegisterTeam(ctx context.Context, gameID int, deliveryID s return 0, err } autoConfirm := time.Now().Before(game.StartAt.Add(-24*time.Hour)) == false - return s.teamsRepo.RegisterTeamWithParticipation(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password, autoConfirm) + id, err := s.teamsRepo.RegisterTeamWithParticipation(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password, autoConfirm) + if err != nil { + return 0, err + } + if s.emailSender == nil { + return id, nil + } + team, err := s.teamsRepo.GetTeamByID(ctx, id) + if err != nil { + return 0, err + } + if err := s.emailSender.Send(ctx, registrationEmail(team, game.Name, s.appBaseURL)); err != nil { + return 0, fmt.Errorf("email send: %w", err) + } + return id, nil +} + +func registrationEmail(team *repos.Team, gameName, baseURL string) email_sender.Message { + link := strings.TrimRight(baseURL, "/") + "/team-story/" + fmt.Sprint(team.ID) + "?password=" + team.Password + body := fmt.Sprintf("Регистрация команды «%s» в игре «%s» прошла успешно.\n\nУчастников: %d\nПароль команды: %s\n\nСсылка для входа в игру: %s\n\nСохраните это письмо и не пересылайте пароль посторонним.", team.Name, gameName, team.ParticipantsCount, team.Password, link) + content := fmt.Sprintf("

Регистрация команды %s в игре %s прошла успешно.

Участников: %d
Пароль команды: %s

%s

Сохраните это письмо и не пересылайте пароль посторонним.

", html.EscapeString(team.Name), html.EscapeString(gameName), team.ParticipantsCount, html.EscapeString(team.Password), email_sender.ButtonHTML(link, "Войти в игру")) + markup := email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/")) + return email_sender.Message{To: team.CaptainEmail, Subject: "Регистрация команды в «Вечернем детективе»", Body: body, HTML: markup} } func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, string, error) {