generated from VLADIMIR/template
add
This commit is contained in:
@@ -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
@@ -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.
@@ -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"
|
||||
|
||||
@@ -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 заменён тестовой реализацией;
|
||||
реальным капитанам тесты ничего не отправляют.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "<script>") || !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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user