This commit is contained in:
2026-09-16 02:44:44 +07:00
parent 27db2067e3
commit 0e42278b36
25 changed files with 1781 additions and 424 deletions
+3
View File
@@ -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
+64 -8
View File
@@ -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 {
Binary file not shown.
+4
View File
@@ -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,
@@ -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"
+54
View File
@@ -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 заменён тестовой реализацией;
реальным капитанам тесты ничего не отправляют.
+52
View File
@@ -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
}
+6
View File
@@ -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{}
+4
View File
@@ -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
+11 -7
View File
@@ -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
}
+129
View File
@@ -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
}
@@ -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
+39 -5
View File
@@ -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
}
@@ -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)
}
@@ -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 := "<!doctype html><html lang=\"ru\"><meta charset=\"utf-8\"><body><h2>Подтвердите участие в игре</h2><p>" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»</p><p>" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d</p>", p.ParticipantsCount)
for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} {
markup += `<p><a href="` + html.EscapeString(link+"&answer="+action.value) + `" style="display:inline-block;padding:12px 20px;background:#342a22;color:#fff;border-radius:5px">` + action.label + `</a></p>`
}
markup += "<p>Ответ можно дать до начала игры. <strong>Решение окончательное: изменить его нельзя.</strong></p><p>Без ответа запись сохраняется со статусом «Ожидает ответа».</p><p>Не пересылайте письмо: ссылка позволяет принять решение за команду.</p></body></html>"
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()
}
@@ -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: "<script>team</script>", 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, "<script>") || !strings.Contains(m.HTML, "&lt;script&gt;") || !strings.Contains(m.Body, "19:00") || !strings.Contains(m.Body, "#token="+token) || !strings.Contains(m.HTML, "answer=cancelled") {
t.Fatalf("invalid email: %+v", m)
}
}
func TestParticipationPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration check")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := fmt.Sprintf("participation_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
if _, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE"); err != nil {
t.Error(err)
}
}()
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
config.ConnConfig.RuntimeParams["search_path"] = schema
config.ConnConfig.RuntimeParams["timezone"] = "UTC"
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE games(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Game',start_at TIMESTAMP NOT NULL,status TEXT DEFAULT 'draft');
CREATE TABLE teams(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'));
INSERT INTO games(start_at) VALUES((NOW() AT TIME ZONE 'UTC')+INTERVAL '12 hours'),((NOW() AT TIME ZONE 'UTC')+INTERVAL '2 days');
INSERT INTO teams(game_id,created_at) VALUES(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`)
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} {
data, err := os.ReadFile("../../../migrations/" + name)
if err != nil {
t.Fatal(err)
}
exec(strings.Split(string(data), "-- +goose Down")[0])
}
repo := teams_repo.NewTeamsRepo(pool)
service := &GameService{teamsRepo: repo, appBaseURL: "https://example.org"}
state := func(id int, want string) {
t.Helper()
team, err := repo.GetTeamByID(ctx, id)
if err != nil || team.ParticipationStatus != want {
t.Fatalf("team %d want %s got %+v, %v", id, want, team, err)
}
}
state(1, "pending")
state(2, "pending") // Existing teams are not changed by the migration.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org');
INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`)
state(3, "pending")
state(4, "confirmed")
state(5, "pending")
// Independent workers must claim each due team just once.
var wg sync.WaitGroup
ids := make(chan int, 8)
errs := make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
p, err := repo.ClaimParticipationEmail(ctx)
if err == nil {
ids <- p.TeamID
} else if !errors.Is(err, pgx.ErrNoRows) {
errs <- err
}
}()
}
wg.Wait()
close(ids)
close(errs)
seen := map[int]bool{}
for id := range ids {
if seen[id] {
t.Fatalf("duplicate claim %d", id)
}
seen[id] = true
}
for err := range errs {
t.Error(err)
}
if len(seen) != 2 || !seen[1] || !seen[3] {
t.Fatalf("wrong eligible teams: %v", seen)
}
exec(`UPDATE teams SET participation_next_attempt_at=NULL`)
sender := &participationSender{err: errors.New("SMTP unavailable")}
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 2 {
t.Fatalf("wanted two attempts: %d", len(sender.messages))
}
team, err := repo.GetTeamByID(ctx, 1)
if err != nil || team.ParticipationEmailError == "" || team.ParticipationEmailSent {
t.Fatalf("failed send not recorded: %+v %v", team, err)
}
sender.err = nil
// Expired lease is recovered after a worker restart.
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id=3`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("expired lease not recovered")
}
exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id=1`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("email retry exceeded the eight-attempt limit")
}
token := participationToken("secret", 1)
if _, err := repo.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("invalid token: %v", err)
}
if _, err := repo.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("unknown token: %v", err)
}
if _, err := repo.GetParticipation(ctx, token); err != nil {
t.Fatal(err)
}
state(1, "pending") // Viewing a link never answers.
answers := make(chan error, 2)
for _, answer := range []string{"confirmed", "cancelled"} {
go func() { _, err := repo.AnswerParticipation(ctx, token, answer); answers <- err }()
}
success, conflict := 0, 0
for range 2 {
err := <-answers
if err == nil {
success++
} else if errors.Is(err, teams_repo.ErrParticipationFinal) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("concurrent answers: success=%d conflict=%d", success, conflict)
}
p, err := repo.GetParticipation(ctx, token)
if err != nil {
t.Fatal(err)
}
if _, err := repo.AnswerParticipation(ctx, token, p.Status); err != nil {
t.Fatalf("same answer not idempotent: %v", err)
}
// Cancellation excludes teams from shared active listings and password-based gameplay.
token3 := participationToken("secret", 3)
if _, err := repo.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
t.Fatal(err)
}
active, err := repo.GetActiveTeamsByGameID(ctx, 1)
if err != nil {
t.Fatal(err)
}
for _, team := range active {
if team.ID == 3 {
t.Fatal("cancelled team in active list")
}
}
if _, _, _, err := service.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got story: %v", err)
}
if err := service.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team added action: %v", err)
}
if _, err := service.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got answers: %v", err)
}
token6 := participationToken("secret", 6)
if err := repo.SetParticipationToken(ctx, 6, token6); err != nil {
t.Fatal(err)
}
exec(`UPDATE games SET status='run' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("running game accepted answer: %v", err)
}
exec(`UPDATE games SET status='draft',start_at=(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 second' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("past scheduled start accepted answer: %v", err)
}
state(6, "pending")
}
@@ -8,6 +8,7 @@ import (
"net/mail"
"strconv"
"strings"
"time"
"unicode/utf8"
)
@@ -72,5 +73,10 @@ func (s *GameService) RegisterTeam(ctx context.Context, gameID int, deliveryID s
if err != nil {
return 0, err
}
return s.teamsRepo.RegisterTeam(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password)
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
if err != nil {
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)
}
+10 -6
View File
@@ -222,7 +222,11 @@ func (s *GameService) AddTeam(
if err != nil {
return nil, err
}
id, err := s.teamsRepo.AddTeam(ctx, creatorId, gameId, name, password)
game, err := s.gamesRepo.GetGameByID(ctx, gameId)
if err != nil {
return nil, err
}
id, err := s.teamsRepo.AddTeamWithParticipation(ctx, creatorId, gameId, name, password, !time.Now().Before(game.StartAt.Add(-24*time.Hour)))
if err != nil {
return nil, err
}
@@ -250,7 +254,7 @@ func (s *GameService) GetTeamActions(ctx context.Context, teamId int, password s
return nil, nil, nil, err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return nil, nil, nil, teams_repo.ErrTeamNotFound
}
@@ -287,7 +291,7 @@ func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password st
return err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return teams_repo.ErrTeamNotFound
}
@@ -386,7 +390,7 @@ func (s *GameService) getTeamAndGame(ctx context.Context, teamId int, password s
if err != nil {
return nil, nil, err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return nil, nil, teams_repo.ErrTeamNotFound
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
@@ -494,7 +498,7 @@ func (s *GameService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnsw
if err != nil {
return nil, err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId)
if err != nil {
return nil, err
}
@@ -581,7 +585,7 @@ func (s *GameService) GetGameResults(ctx context.Context, gameId int) (*GameResu
if err != nil {
return nil, err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId)
if err != nil {
return nil, err
}
+12 -8
View File
@@ -3,12 +3,16 @@ package game_service
import "evening_detective_server/internal/modules/storytelling"
type Team struct {
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
ActionsCount int
Applications []*storytelling.Application
ParticipationStatus string
ParticipationSource string
ParticipationEmailError string
ParticipationEmailSent bool
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
ActionsCount int
Applications []*storytelling.Application
}
+10 -6
View File
@@ -6,12 +6,16 @@ func mapTeam(
o *repos.Team,
) *Team {
return &Team{
ID: o.ID,
CaptainEmail: o.CaptainEmail,
ParticipantsCount: o.ParticipantsCount,
Name: o.Name,
Status: o.Status,
Password: o.Password,
ParticipationStatus: o.ParticipationStatus,
ParticipationSource: o.ParticipationSource,
ParticipationEmailError: o.ParticipationEmailError,
ParticipationEmailSent: o.ParticipationEmailSent,
ID: o.ID,
CaptainEmail: o.CaptainEmail,
ParticipantsCount: o.ParticipantsCount,
Name: o.Name,
Status: o.Status,
Password: o.Password,
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ func (s *GameService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin
if err != nil {
return nil, "", err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, id)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, id)
if err != nil {
return nil, "", err
}
@@ -0,0 +1,23 @@
-- +goose Up
ALTER TABLE teams
ADD COLUMN participation_status TEXT NOT NULL DEFAULT 'pending' CHECK (participation_status IN ('pending', 'confirmed', 'cancelled')),
ADD COLUMN participation_source TEXT NOT NULL DEFAULT '',
ADD COLUMN participation_answered_at TIMESTAMPTZ,
ADD COLUMN participation_token_hash BYTEA,
ADD COLUMN participation_sent_at TIMESTAMPTZ,
ADD COLUMN participation_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN participation_next_attempt_at TIMESTAMPTZ,
ADD COLUMN participation_email_error TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX teams_participation_token ON teams (participation_token_hash);
-- +goose Down
DROP INDEX teams_participation_token;
ALTER TABLE teams
DROP COLUMN participation_status,
DROP COLUMN participation_source,
DROP COLUMN participation_answered_at,
DROP COLUMN participation_token_hash,
DROP COLUMN participation_sent_at,
DROP COLUMN participation_attempts,
DROP COLUMN participation_next_attempt_at,
DROP COLUMN participation_email_error;
+646 -381
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -36,6 +36,60 @@ var (
_ = metadata.Join
)
func request_EveningDetectiveServer_GetParticipation_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetParticipationReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.GetParticipation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_GetParticipation_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetParticipationReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetParticipation(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_AnswerParticipation_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AnswerParticipationReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
msg, err := client.AnswerParticipation(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_AnswerParticipation_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq AnswerParticipationReq
metadata runtime.ServerMetadata
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.AnswerParticipation(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_Ping_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq PingReq
@@ -2863,6 +2917,46 @@ func local_request_EveningDetectiveServer_SetTeamAnswerScore_0(ctx context.Conte
// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterEveningDetectiveServerHandlerFromEndpoint instead.
// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call.
func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runtime.ServeMux, server EveningDetectiveServerServer) error {
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_GetParticipation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation", runtime.WithHTTPPathPattern("/api/participation/get"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_GetParticipation_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_GetParticipation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AnswerParticipation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation", runtime.WithHTTPPathPattern("/api/participation/answer"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_AnswerParticipation_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_AnswerParticipation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_Ping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -4363,6 +4457,40 @@ func RegisterEveningDetectiveServerHandler(ctx context.Context, mux *runtime.Ser
// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in
// "EveningDetectiveServerClient" to call the correct interceptors. This client ignores the HTTP middlewares.
func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runtime.ServeMux, client EveningDetectiveServerClient) error {
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_GetParticipation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation", runtime.WithHTTPPathPattern("/api/participation/get"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_GetParticipation_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_GetParticipation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPost, pattern_EveningDetectiveServer_AnswerParticipation_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation", runtime.WithHTTPPathPattern("/api/participation/answer"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_AnswerParticipation_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_AnswerParticipation_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_EveningDetectiveServer_Ping_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@@ -5608,6 +5736,8 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
}
var (
pattern_EveningDetectiveServer_GetParticipation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "participation", "get"}, ""))
pattern_EveningDetectiveServer_AnswerParticipation_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "participation", "answer"}, ""))
pattern_EveningDetectiveServer_Ping_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "ping"}, ""))
pattern_EveningDetectiveServer_Echo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "test", "echo"}, ""))
pattern_EveningDetectiveServer_Signup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "auth", "signup"}, ""))
@@ -5684,6 +5814,8 @@ var (
)
var (
forward_EveningDetectiveServer_GetParticipation_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AnswerParticipation_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Ping_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Echo_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_Signup_0 = runtime.ForwardResponseMessage
+76
View File
@@ -20,6 +20,8 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
EveningDetectiveServer_GetParticipation_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/GetParticipation"
EveningDetectiveServer_AnswerParticipation_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AnswerParticipation"
EveningDetectiveServer_Ping_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Ping"
EveningDetectiveServer_Echo_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Echo"
EveningDetectiveServer_Signup_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/Signup"
@@ -99,6 +101,8 @@ const (
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type EveningDetectiveServerClient interface {
GetParticipation(ctx context.Context, in *GetParticipationReq, opts ...grpc.CallOption) (*ParticipationRsp, error)
AnswerParticipation(ctx context.Context, in *AnswerParticipationReq, opts ...grpc.CallOption) (*ParticipationRsp, error)
Ping(ctx context.Context, in *PingReq, opts ...grpc.CallOption) (*PingRsp, error)
Echo(ctx context.Context, in *EchoReq, opts ...grpc.CallOption) (*EchoRsp, error)
Signup(ctx context.Context, in *SignupReq, opts ...grpc.CallOption) (*SignupRsp, error)
@@ -192,6 +196,26 @@ func NewEveningDetectiveServerClient(cc grpc.ClientConnInterface) EveningDetecti
return &eveningDetectiveServerClient{cc}
}
func (c *eveningDetectiveServerClient) GetParticipation(ctx context.Context, in *GetParticipationReq, opts ...grpc.CallOption) (*ParticipationRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParticipationRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_GetParticipation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) AnswerParticipation(ctx context.Context, in *AnswerParticipationReq, opts ...grpc.CallOption) (*ParticipationRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParticipationRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_AnswerParticipation_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) Ping(ctx context.Context, in *PingReq, opts ...grpc.CallOption) (*PingRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PingRsp)
@@ -926,6 +950,8 @@ func (c *eveningDetectiveServerClient) SetTeamAnswerScore(ctx context.Context, i
// All implementations must embed UnimplementedEveningDetectiveServerServer
// for forward compatibility.
type EveningDetectiveServerServer interface {
GetParticipation(context.Context, *GetParticipationReq) (*ParticipationRsp, error)
AnswerParticipation(context.Context, *AnswerParticipationReq) (*ParticipationRsp, error)
Ping(context.Context, *PingReq) (*PingRsp, error)
Echo(context.Context, *EchoReq) (*EchoRsp, error)
Signup(context.Context, *SignupReq) (*SignupRsp, error)
@@ -1019,6 +1045,12 @@ type EveningDetectiveServerServer interface {
// pointer dereference when methods are called.
type UnimplementedEveningDetectiveServerServer struct{}
func (UnimplementedEveningDetectiveServerServer) GetParticipation(context.Context, *GetParticipationReq) (*ParticipationRsp, error) {
return nil, status.Error(codes.Unimplemented, "method GetParticipation not implemented")
}
func (UnimplementedEveningDetectiveServerServer) AnswerParticipation(context.Context, *AnswerParticipationReq) (*ParticipationRsp, error) {
return nil, status.Error(codes.Unimplemented, "method AnswerParticipation not implemented")
}
func (UnimplementedEveningDetectiveServerServer) Ping(context.Context, *PingReq) (*PingRsp, error) {
return nil, status.Error(codes.Unimplemented, "method Ping not implemented")
}
@@ -1260,6 +1292,42 @@ func RegisterEveningDetectiveServerServer(s grpc.ServiceRegistrar, srv EveningDe
s.RegisterService(&EveningDetectiveServer_ServiceDesc, srv)
}
func _EveningDetectiveServer_GetParticipation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetParticipationReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).GetParticipation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_GetParticipation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).GetParticipation(ctx, req.(*GetParticipationReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_AnswerParticipation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AnswerParticipationReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).AnswerParticipation(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_AnswerParticipation_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).AnswerParticipation(ctx, req.(*AnswerParticipationReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PingReq)
if err := dec(in); err != nil {
@@ -2581,6 +2649,14 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
ServiceName: "crabs.evening_detective_server.EveningDetectiveServer",
HandlerType: (*EveningDetectiveServerServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetParticipation",
Handler: _EveningDetectiveServer_GetParticipation_Handler,
},
{
MethodName: "AnswerParticipation",
Handler: _EveningDetectiveServer_AnswerParticipation_Handler,
},
{
MethodName: "Ping",
Handler: _EveningDetectiveServer_Ping_Handler,