diff --git a/.env.example b/.env.example index 7d3a680..55b7b46 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,14 @@ SMTP_REPLY_TO=evening_detective@crabs-games.art SMTP_TIMEOUT=10s JWT_SECRET=your_secret +PARTICIPATION_SECRET=another_secret # Публичный адрес приложения: ссылки «Войти в игру» в письмах и QR-коды команд. # Должен совпадать с базовым адресом существующих QR-кодов фронтенда. # Если не задан или не http(s), PDF команд недоступен, ссылка в письма не добавляется. APP_BASE_URL=https://evening-detective.crabs-games.art +# Часовой пояс даты игры в письмах подтверждения (хранение и расчёт сроков — UTC). +GAME_TIMEZONE=Asia/Barnaul S3_HOST=http://0.0.0.0:9000 S3_USER=rustfs diff --git a/api/main.proto b/api/main.proto index 1ecbaa9..0d39fd2 100644 --- a/api/main.proto +++ b/api/main.proto @@ -35,6 +35,24 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { }; service EveningDetectiveServer { + rpc GetParticipation(GetParticipationReq) returns (ParticipationRsp) { + option (google.api.http) = { post: "/api/participation/get" body: "*" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + security : {}; + tags : "Подтверждение участия"; + summary : "Получить состояние участия"; + description: "Возвращает команду, игру и текущее решение по персональному токену из письма. Авторизация JWT не требуется. Чтение не меняет статус участия. Токен передаётся в теле запроса. Недействительная ссылка и ошибки обработки возвращаются в поле error при HTTP 200."; + }; + } + rpc AnswerParticipation(AnswerParticipationReq) returns (ParticipationRsp) { + option (google.api.http) = { post: "/api/participation/answer" body: "*" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + security : {}; + tags : "Подтверждение участия"; + summary : "Подтвердить или отменить участие"; + description: "Принимает окончательный ответ капитана: confirmed или cancelled. Авторизация JWT не требуется; доступ определяется персональным токеном. Первый ответ сохраняется, повтор того же ответа успешен, противоположный ответ отклоняется. Новый ответ принимается только до планового начала игры и пока игра имеет статус draft. Ошибки ссылки, значения answer, закрытого приёма или изменения решения возвращаются в поле error при HTTP 200; при уже принятом решении возвращается его текущее состояние."; + }; + } rpc Ping(PingReq) returns (PingRsp) { option (google.api.http) = { get: "/api/test/ping" @@ -1452,15 +1470,53 @@ message GetScreenResultsRsp { repeated TeamResult teams = 3; } +message GetParticipationReq { + // Персональный токен из письма: 64 шестнадцатеричных символа. Не является JWT. + string token = 1; +} + +message AnswerParticipationReq { + // Персональный токен из письма: 64 шестнадцатеричных символа. Не является JWT. + string token = 1; + // Окончательное решение: confirmed — участвуем, cancelled — отменяем запись. + string answer = 2; +} + +message ParticipationRsp { + // Пустая строка при успехе; иначе сообщение об ошибке для пользователя. + string error = 1; + // Название команды. Данные доступны только по действительному токену. + string team_name = 2; + // Название игры. + string game_name = 3; + // Плановое начало игры, UTC (RFC 3339 в JSON). + google.protobuf.Timestamp start_at = 4; + // Заявленное число участников команды. + int32 participants_count = 5; + // pending — ожидает ответа, confirmed — подтверждено, cancelled — отменено. + string status = 6; + // Новые ответы закрыты: наступило плановое начало или игра вышла из draft. + // Изменение уже принятого решения запрещено независимо от этого поля. + bool closed = 7; +} + message Team { - int32 id = 1; - string name = 2; - string status = 3; - string password = 4; - int32 actions_count = 5; - repeated Application applications = 6; - string captain_email = 7; - int32 participants_count = 8; + // Статус участия: pending, confirmed или cancelled. Поле панели организатора. + string participation_status = 9; + // Источник решения: captain, automatic либо пустая строка до ответа. + string participation_source = 10; + // Последняя ошибка отправки; пустая строка, если ошибки нет. + string participation_email_error = 11; + // Письмо принято SMTP-сервером; не означает прочтение или доставку во входящие. + bool participation_email_sent = 12; + int32 id = 1; + string name = 2; + string status = 3; + string password = 4; + int32 actions_count = 5; + repeated Application applications = 6; + string captain_email = 7; + int32 participants_count = 8; } message GetGameReq { diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 472c0b6..af9943f 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 894b376..971c0aa 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -148,6 +148,8 @@ func main() { os.Getenv("APP_BASE_URL"), ) + go gameService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) + // Create a listener on TCP port lis, err := net.Listen("tcp", ":8080") if err != nil { @@ -160,6 +162,8 @@ func main() { grpc.UnaryInterceptor( processor_jwt.NewAuthorizationInterceptor( map[string]bool{ + "/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation": true, + "/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation": true, "/crabs.evening_detective_server.EveningDetectiveServer/Ping": true, "/crabs.evening_detective_server.EveningDetectiveServer/Echo": true, "/crabs.evening_detective_server.EveningDetectiveServer/Signup": true, diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index f0d5772..4772d87 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -972,6 +972,76 @@ ] } }, + "/api/participation/answer": { + "post": { + "summary": "Подтвердить или отменить участие", + "description": "Принимает окончательный ответ капитана: confirmed или cancelled. Авторизация JWT не требуется; доступ определяется персональным токеном. Первый ответ сохраняется, повтор того же ответа успешен, противоположный ответ отклоняется. Новый ответ принимается только до планового начала игры и пока игра имеет статус draft. Ошибки ссылки, значения answer, закрытого приёма или изменения решения возвращаются в поле error при HTTP 200; при уже принятом решении возвращается его текущее состояние.", + "operationId": "EveningDetectiveServer_AnswerParticipation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/evening_detective_serverParticipationRsp" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/evening_detective_serverAnswerParticipationReq" + } + } + ], + "tags": [ + "Подтверждение участия" + ], + "security": [] + } + }, + "/api/participation/get": { + "post": { + "summary": "Получить состояние участия", + "description": "Возвращает команду, игру и текущее решение по персональному токену из письма. Авторизация JWT не требуется. Чтение не меняет статус участия. Токен передаётся в теле запроса. Недействительная ссылка и ошибки обработки возвращаются в поле error при HTTP 200.", + "operationId": "EveningDetectiveServer_GetParticipation", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/evening_detective_serverParticipationRsp" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/evening_detective_serverGetParticipationReq" + } + } + ], + "tags": [ + "Подтверждение участия" + ], + "security": [] + } + }, "/api/privacy": { "get": { "summary": "Получить текст Политики конфиденциальности", @@ -3004,6 +3074,19 @@ }, "title": "Ответ команды на вопрос дела" }, + "evening_detective_serverAnswerParticipationReq": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Персональный токен из письма: 64 шестнадцатеричных символа. Не является JWT." + }, + "answer": { + "type": "string", + "description": "Окончательное решение: confirmed — участвуем, cancelled — отменяем запись." + } + } + }, "evening_detective_serverAnswerScore": { "type": "object", "properties": { @@ -3364,6 +3447,15 @@ } } }, + "evening_detective_serverGetParticipationReq": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Персональный токен из письма: 64 шестнадцатеричных символа. Не является JWT." + } + } + }, "evening_detective_serverGetPermissionsRsp": { "type": "object", "properties": { @@ -3627,6 +3719,41 @@ } } }, + "evening_detective_serverParticipationRsp": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Пустая строка при успехе; иначе сообщение об ошибке для пользователя." + }, + "teamName": { + "type": "string", + "description": "Название команды. Данные доступны только по действительному токену." + }, + "gameName": { + "type": "string", + "description": "Название игры." + }, + "startAt": { + "type": "string", + "format": "date-time", + "description": "Плановое начало игры, UTC (RFC 3339 в JSON)." + }, + "participantsCount": { + "type": "integer", + "format": "int32", + "description": "Заявленное число участников команды." + }, + "status": { + "type": "string", + "description": "pending — ожидает ответа, confirmed — подтверждено, cancelled — отменено." + }, + "closed": { + "type": "boolean", + "description": "Новые ответы закрыты: наступило плановое начало или игра вышла из draft.\nИзменение уже принятого решения запрещено независимо от этого поля." + } + } + }, "evening_detective_serverPauseGameRsp": { "type": "object", "properties": { @@ -3996,6 +4123,22 @@ "evening_detective_serverTeam": { "type": "object", "properties": { + "participationStatus": { + "type": "string", + "description": "Статус участия: pending, confirmed или cancelled. Поле панели организатора." + }, + "participationSource": { + "type": "string", + "description": "Источник решения: captain, automatic либо пустая строка до ответа." + }, + "participationEmailError": { + "type": "string", + "description": "Последняя ошибка отправки; пустая строка, если ошибки нет." + }, + "participationEmailSent": { + "type": "boolean", + "description": "Письмо принято SMTP-сервером; не означает прочтение или доставку во входящие." + }, "id": { "type": "integer", "format": "int32" diff --git a/docs/PARTICIPATION.md b/docs/PARTICIPATION.md new file mode 100644 index 0000000..132e7cb --- /dev/null +++ b/docs/PARTICIPATION.md @@ -0,0 +1,54 @@ +# Подтверждение участия + +Для всех форматов игр за 24 часа до `start_at` сервер отправляет письмо капитану. +Проверка запускается при старте сервера, затем раз в минуту. Отправка последовательная: +большая рассылка занимает дополнительное время. Команды без email видны организатору, +но письмо им отправить невозможно. + +## Правила + +- `pending` — ожидает ответа; команда сохраняет право участвовать. +- `confirmed` — подтверждено капитаном или автоматически при регистрации менее чем за сутки. +- `cancelled` — отменено; команда остаётся в истории организатора, но исключается из игры, + ответов организатору, результатов, карты и PDF с QR-кодами. +- Ровно за 24 часа автоматического подтверждения нет. +- Ответ окончательный: повтор того же ответа успешен, противоположный отклоняется. +- После планового начала или выхода игры из `draft` новые ответы не принимаются. +- Перенос игры и повторный сбор подтверждений не поддерживаются. + +Правило поздней регистрации вычисляется в сервисе перед созданием команды для формы и ручного добавления. +Существующие команды миграция не изменяет: они остаются в статусе `pending`. +Даты без часового пояса в существующих `games.start_at` и `teams.created_at` считаются UTC. + +## Запуск + +1. Применить миграцию `20260915120000_add_team_participation.sql` через обычный `goose up`. +2. Обновить сервер и фронтенд вместе: фронтенд должен обслуживать `/participation`. +3. Проверить существующие SMTP-настройки, `APP_BASE_URL`, `PARTICIPATION_SECRET`. + `GAME_TIMEZONE` задаёт часовой пояс письма, по умолчанию `Asia/Barnaul`. + +Рассылка запускается автоматически. Если `APP_BASE_URL`, `PARTICIPATION_SECRET` или часовой пояс +некорректны, сервер пишет причину отключения рассылки в журнал. +Не менять `PARTICIPATION_SECRET` между попытками отправки: HMAC-токен привязан к команде и этому секрету. +В БД хранится только SHA-256 токена. Уже отправленные ссылки проверяются по сохранённому хешу. +Токен передаётся в URL-фрагменте и теле API-запроса, не в query string. +Не включать тела запросов `/api/participation/*` в access-логи. + +Неудачные попытки повторяются через пять минут автоматически, максимум восемь раз на команду. +После восьмой неудачи новые попытки не запускаются; ошибка остаётся видна организатору. +Пятиминутная аренда задания защищает от одновременной обработки серверами и восстанавливается +после падения. Одна попытка ограничена минутой. Ошибки сохранения результата записываются в журнал. +SMTP не обеспечивает ровно одну доставку: если письмо принято, а результат не сохранён, +возможен дубль с той же ссылкой. `participation_sent_at` означает принятие письма SMTP, +а не прочтение письма или гарантированное попадание во входящие. + +## Проверка + +```sh +go test ./... +TEST_DATABASE_URL='host=localhost port=5432 user=test dbname=test sslmode=disable' \ + go test ./internal/repos/teams_repo ./internal/services/game_service -count=1 +``` + +Интеграционные тесты создают и удаляют собственные схемы. SMTP заменён тестовой реализацией; +реальным капитанам тесты ничего не отправляют. diff --git a/internal/app/participation.go b/internal/app/participation.go new file mode 100644 index 0000000..f3a9cae --- /dev/null +++ b/internal/app/participation.go @@ -0,0 +1,52 @@ +package app + +import ( + "context" + "errors" + "evening_detective_server/internal/repos/teams_repo" + "evening_detective_server/proto" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func mapParticipationResponse(p *teams_repo.Participation) *proto.ParticipationRsp { + result := &proto.ParticipationRsp{} + if p != nil { + result.TeamName = p.TeamName + result.GameName = p.GameName + result.StartAt = timestamppb.New(p.StartAt) + result.ParticipantsCount = p.ParticipantsCount + result.Status = p.Status + result.Closed = p.Closed + } + return result +} + +func participationError(err error) string { + if errors.Is(err, teams_repo.ErrParticipationLink) || errors.Is(err, teams_repo.ErrParticipationFinal) || errors.Is(err, teams_repo.ErrParticipationClosed) { + return err.Error() + } + return "Не удалось обработать ответ. Попробуйте позже." +} +func (s *server) GetParticipation(ctx context.Context, req *proto.GetParticipationReq) (*proto.ParticipationRsp, error) { + _ = grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store")) + p, err := s.gameService.GetParticipation(ctx, req.Token) + result := mapParticipationResponse(p) + if err != nil { + result.Error = participationError(err) + } + return result, nil +} +func (s *server) AnswerParticipation(ctx context.Context, req *proto.AnswerParticipationReq) (*proto.ParticipationRsp, error) { + _ = grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store")) + if req.Answer != "confirmed" && req.Answer != "cancelled" { + return &proto.ParticipationRsp{Error: "Выберите подтверждение или отмену участия"}, nil + } + p, err := s.gameService.AnswerParticipation(ctx, req.Token, req.Answer) + result := mapParticipationResponse(p) + if err != nil { + result.Error = participationError(err) + } + return result, nil +} diff --git a/internal/app/server.go b/internal/app/server.go index 86ccf92..291af35 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -203,6 +203,9 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro return nil, status.Error(codes.NotFound, "игра не найдена") } for _, team := range game.Teams { + if team.ParticipationStatus == "cancelled" { + continue + } if err := s.mapRepo.Ensure(ctx, int(req.GameId), "team", team.ID); err != nil { return nil, status.Error(codes.Internal, "не удалось сохранить карту") } @@ -222,6 +225,9 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro } teamNames := map[int]string{} for _, team := range game.Teams { + if team.ParticipationStatus == "cancelled" { + continue + } teamNames[team.ID] = team.Name } screenStates := map[int]*proto.ScreenState{} diff --git a/internal/app/team_mapper.go b/internal/app/team_mapper.go index 0b7aac6..1d27e13 100644 --- a/internal/app/team_mapper.go +++ b/internal/app/team_mapper.go @@ -20,6 +20,10 @@ func mapTeam( func mapTeamDetails(o *game_service.Team) *proto.Team { team := mapTeam(o) + team.ParticipationStatus = o.ParticipationStatus + team.ParticipationSource = o.ParticipationSource + team.ParticipationEmailError = o.ParticipationEmailError + team.ParticipationEmailSent = o.ParticipationEmailSent team.CaptainEmail = o.CaptainEmail team.ParticipantsCount = o.ParticipantsCount return team diff --git a/internal/repos/team.go b/internal/repos/team.go index 829b70a..543b641 100644 --- a/internal/repos/team.go +++ b/internal/repos/team.go @@ -1,11 +1,15 @@ package repos type Team struct { - CaptainEmail string - ParticipantsCount int32 - ID int - Name string - Status string - Password string - GameId int + ParticipationStatus string + ParticipationSource string + ParticipationEmailError string + ParticipationEmailSent bool + CaptainEmail string + ParticipantsCount int32 + ID int + Name string + Status string + Password string + GameId int } diff --git a/internal/repos/teams_repo/participation.go b/internal/repos/teams_repo/participation.go new file mode 100644 index 0000000..fe3fe1b --- /dev/null +++ b/internal/repos/teams_repo/participation.go @@ -0,0 +1,129 @@ +package teams_repo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/jackc/pgx/v5" +) + +var ErrParticipationLink = errors.New("Ссылка недействительна") +var ErrParticipationClosed = errors.New("Приём ответов закрыт") +var ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено") + +const maxParticipationEmailAttempts = 8 + +type Participation struct { + TeamID int + TeamName string + GameName string + StartAt time.Time + ParticipantsCount int32 + Status string + Closed bool + Email string + Attempts int +} + +func tokenHash(token string) ([]byte, error) { + if len(token) != 64 { + return nil, ErrParticipationLink + } + raw, err := hex.DecodeString(token) + if err != nil || len(raw) != 32 { + return nil, ErrParticipationLink + } + hash := sha256.Sum256([]byte(token)) + return hash[:], nil +} + +func (r *TeamsRepo) GetParticipation(ctx context.Context, token string) (*Participation, error) { + hash, err := tokenHash(token) + if err != nil { + return nil, err + } + p := &Participation{} + err = r.pool.QueryRow(ctx, `SELECT t.id, t.name, g.name, g.start_at, t.participants_count, t.participation_status, + g.status <> 'draft' OR g.start_at <= (NOW() AT TIME ZONE 'UTC') + FROM teams t JOIN games g ON g.id=t.game_id WHERE t.participation_token_hash=$1`, hash).Scan( + &p.TeamID, &p.TeamName, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Status, &p.Closed) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrParticipationLink + } + return p, err +} + +func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) { + if answer != "confirmed" && answer != "cancelled" { + return nil, errors.New("Выберите подтверждение или отмену участия") + } + hash, err := tokenHash(token) + if err != nil { + return nil, err + } + // The condition is rechecked after a concurrent answer releases its row lock. + _, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id + WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g) + UPDATE teams t SET participation_status=$2, + participation_source='captain', participation_answered_at=NOW() + FROM eligible_game g WHERE g.id=t.game_id AND t.participation_token_hash=$1 + AND t.participation_status='pending'`, hash, answer) + if err != nil { + return nil, err + } + p, err := r.GetParticipation(ctx, token) + if err != nil { + return nil, err + } + if p.Status == answer { + return p, nil + } + if p.Status != "pending" { + return p, ErrParticipationFinal + } + return p, ErrParticipationClosed +} + +// A five-minute lease survives restarts and keeps concurrent workers from sending the same job. +func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) { + p := &Participation{} + err := r.pool.QueryRow(ctx, `WITH candidate AS ( + SELECT t.id FROM teams t JOIN games g ON g.id=t.game_id + WHERE t.participation_status='pending' AND t.captain_email<>'' AND t.participation_sent_at IS NULL + AND t.participation_attempts < $1 + AND (t.participation_next_attempt_at IS NULL OR t.participation_next_attempt_at <= NOW()) + AND g.status='draft' AND g.start_at > (NOW() AT TIME ZONE 'UTC') + AND g.start_at <= (NOW() AT TIME ZONE 'UTC') + INTERVAL '24 hours' + ORDER BY t.participation_next_attempt_at NULLS FIRST, t.id + FOR UPDATE OF t SKIP LOCKED LIMIT 1 + ) UPDATE teams t SET participation_next_attempt_at=NOW()+INTERVAL '5 minutes', + participation_attempts=t.participation_attempts+1, participation_email_error='' + FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id + + RETURNING t.id,t.name,t.captain_email,g.name,g.start_at,t.participants_count,t.participation_attempts`, maxParticipationEmailAttempts).Scan( + &p.TeamID, &p.TeamName, &p.Email, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Attempts) + return p, err +} + +func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error { + hash, err := tokenHash(token) + if err != nil { + return err + } + _, err = r.pool.Exec(ctx, `UPDATE teams SET participation_token_hash=$2 WHERE id=$1`, id, hash) + return err +} + +func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error { + message := "" + if sendErr != nil { + message = sendErr.Error() + } + _, err := r.pool.Exec(ctx, `UPDATE teams SET participation_email_error=$3, + participation_sent_at=CASE WHEN $3='' THEN NOW() ELSE participation_sent_at END + WHERE id=$1 AND participation_attempts=$2`, id, attempt, message) + return err +} diff --git a/internal/repos/teams_repo/registration_test.go b/internal/repos/teams_repo/registration_test.go index 0590f36..49776a0 100644 --- a/internal/repos/teams_repo/registration_test.go +++ b/internal/repos/teams_repo/registration_test.go @@ -58,6 +58,13 @@ func TestRegistrationPostgres(t *testing.T) { if _, err := pool.Exec(ctx, strings.Split(string(migration), "-- +goose Down")[0]); err != nil { t.Fatal(err) } + participationMigration, err := os.ReadFile("../../../migrations/20260915120000_add_team_participation.sql") + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, strings.Split(string(participationMigration), "-- +goose Down")[0]); err != nil { + t.Fatal(err) + } repo := NewTeamsRepo(pool) type result struct { id int diff --git a/internal/repos/teams_repo/repo.go b/internal/repos/teams_repo/repo.go index 0002722..c981441 100644 --- a/internal/repos/teams_repo/repo.go +++ b/internal/repos/teams_repo/repo.go @@ -32,6 +32,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error name, captain_email, participants_count, + participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL, status, password, game_id @@ -44,6 +45,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error &team.Name, &team.CaptainEmail, &team.ParticipantsCount, + &team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent, &team.Status, &team.Password, &team.GameId, @@ -69,6 +71,7 @@ func (r *TeamsRepo) GetTeamsByGameID( name, captain_email, participants_count, + participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL, status, password FROM teams @@ -89,6 +92,7 @@ func (r *TeamsRepo) GetTeamsByGameID( &team.Name, &team.CaptainEmail, &team.ParticipantsCount, + &team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent, &team.Status, &team.Password, ) @@ -111,16 +115,26 @@ func (r *TeamsRepo) AddTeam( name string, password string, ) (int, error) { + return r.AddTeamWithParticipation(ctx, creatorId, gameId, name, password, false) +} + +func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorId, gameId int, name, password string, autoConfirm bool) (int, error) { id := 0 + status, source := "pending", "" + if autoConfirm { + status, source = "confirmed", "automatic" + } err := r.pool.QueryRow( ctx, - `INSERT INTO teams (name, creator_id, game_id, password) - VALUES ($1, $2, $3, $4) + `INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at) + VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END) RETURNING id`, name, creatorId, gameId, password, + status, + source, ).Scan(&id) if err != nil { @@ -168,6 +182,10 @@ var ErrRegistrationClosed = errors.New("Игра не найдена или ре // RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы. func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) { + return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false) +} + +func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) { var id int // Повтор уже принятой доставки успешен и после старта игры. err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id) @@ -177,14 +195,30 @@ func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, na if !errors.Is(err, pgx.ErrNoRows) { return 0, err } + status, source := "pending", "" + if autoConfirm { + status, source = "confirmed", "automatic" + } err = r.pool.QueryRow(ctx, ` - INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password) - SELECT id, $2, $3, $4, $5, $6 FROM games WHERE id = $1 AND status = 'draft' + INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password, participation_status, participation_source, participation_answered_at) + SELECT id, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END FROM games WHERE id = $1 AND status = 'draft' ON CONFLICT (game_id, registration_delivery_id) DO UPDATE SET registration_delivery_id = EXCLUDED.registration_delivery_id - RETURNING id`, gameID, deliveryID, name, email, participantsCount, password).Scan(&id) + RETURNING id`, gameID, deliveryID, name, email, participantsCount, password, status, source).Scan(&id) if errors.Is(err, pgx.ErrNoRows) { return 0, ErrRegistrationClosed } return id, err } + +// GetActiveTeamsByGameID excludes cancelled registrations from gameplay exports and results. +func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) { + teams, err := r.GetTeamsByGameID(ctx, gameID) + active := teams[:0] + for _, team := range teams { + if team.ParticipationStatus != "cancelled" { + active = append(active, team) + } + } + return active, err +} diff --git a/internal/services/game_service/finished_story_test.go b/internal/services/game_service/finished_story_test.go index 8e20ed9..742570e 100644 --- a/internal/services/game_service/finished_story_test.go +++ b/internal/services/game_service/finished_story_test.go @@ -35,7 +35,9 @@ func TestFinishedTeamStoryPostgres(t *testing.T) { CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT, status TEXT, password TEXT, game_id INT); INSERT INTO games VALUES (1, 'Game', '', NOW(), 'online', 'finish', 1, NULL, NULL); - INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1);`) + INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1); + ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '', + ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`) if err != nil { t.Fatal(err) } diff --git a/internal/services/game_service/participation.go b/internal/services/game_service/participation.go new file mode 100644 index 0000000..dd90a01 --- /dev/null +++ b/internal/services/game_service/participation.go @@ -0,0 +1,104 @@ +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() +} diff --git a/internal/services/game_service/participation_test.go b/internal/services/game_service/participation_test.go new file mode 100644 index 0000000..9ee4106 --- /dev/null +++ b/internal/services/game_service/participation_test.go @@ -0,0 +1,241 @@ +package game_service + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "evening_detective_server/internal/modules/email_sender" + "evening_detective_server/internal/repos/teams_repo" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type participationSender struct { + messages []email_sender.Message + err error +} + +func (s *participationSender) Send(_ context.Context, m email_sender.Message) error { + s.messages = append(s.messages, m) + return s.err +} + +func TestParticipationEmail(t *testing.T) { + token := participationToken("test-secret", 42) + if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) { + t.Fatal("token isolation or retry stability broken") + } + p := &teams_repo.Participation{TeamName: "", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5} + m := participationEmail(p, "https://example.org", token, time.FixedZone("+07", 7*3600)) + if strings.Contains(m.HTML, "