generated from VLADIMIR/template
add registration
This commit is contained in:
@@ -25,3 +25,6 @@ S3_PASSWORD=your_password
|
||||
S3_BUCKET=evening-detective
|
||||
|
||||
FILE_PREFIX_DOMAIN=http://0.0.0.0:8090/api/files/
|
||||
|
||||
# Секрет webhook Яндекс Форм. Пустое значение отключает регистрацию.
|
||||
YANDEX_FORMS_TOKEN=
|
||||
|
||||
@@ -128,3 +128,62 @@ MCP-эндпоинт: `http://localhost:8090/api/mcp`. ВНИМАНИЕ: REST-ga
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Регистрация команды из Яндекс Форм
|
||||
|
||||
`POST /api/games/{id}/registrations` — отдельный HTTP webhook, без пользовательского JWT.
|
||||
Капитан не должен иметь учётную запись. Создаётся обычная команда с автоматически
|
||||
сгенерированным паролем; почта капитана и количество участников видны организатору
|
||||
в списке команд игры. Письма автоматически не отправляются.
|
||||
|
||||
Перед запуском примените миграции и задайте `YANDEX_FORMS_TOKEN` в окружении сервера
|
||||
(случайный секрет, например результат `openssl rand -hex 32`). Пустое значение отключает ручку.
|
||||
Секрет настраивается только в интеграции формы, не в её публичных полях.
|
||||
|
||||
В Яндекс Формах откройте **Интеграции → Запрос заданным методом**:
|
||||
|
||||
- Метод: `POST`.
|
||||
- URL: `https://<адрес-сервера>/api/games/42/registrations`, где `42` — ID игры.
|
||||
- Заголовок `Authorization`: `Bearer <YANDEX_FORMS_TOKEN>`.
|
||||
- Заголовок `Content-Type`: `application/json`.
|
||||
- Тело запроса:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Следствие ведут котики",
|
||||
"email": "captain@example.org",
|
||||
"participantsCount": 5
|
||||
}
|
||||
```
|
||||
|
||||
Подставьте вместо примеров переменные ответов на вопросы «Название команды»,
|
||||
«Почта капитана», «Количество участников». Для строк используйте фильтр JSON,
|
||||
чтобы кавычки и переносы строк корректно экранировались. Число участников передаётся
|
||||
целым JSON-числом без кавычек: от 1 до 2147483647. Название — 1–255 символов после
|
||||
удаления пробелов по краям; email — одиночный адрес длиной до 254 байт.
|
||||
|
||||
Яндекс передаёт заголовок `X-Delivery-Id`: он обязателен и используется для защиты
|
||||
от повторной доставки. Один ID доставки в пределах игры возвращает ту же команду,
|
||||
не меняя её данные и пароль. Новая отправка формы с другим ID создаёт новую команду,
|
||||
даже если почта и название совпадают. Для ручного теста задайте этот заголовок сами.
|
||||
|
||||
Успех: `200 {"id":123}`. Пароль команды в ответ webhook не передаётся.
|
||||
Регистрация новой команды доступна только для игры в статусе `draft`.
|
||||
Повтор уже принятой доставки успешен и после старта игры.
|
||||
Ошибки: `400` — поля/JSON/ID доставки; `401` — токен; `409` — игра отсутствует или
|
||||
регистрация закрыта; `503` — интеграция выключена; `500` — внутренняя ошибка.
|
||||
Лимит тела — 16 КиБ, время обработки — до 4 секунд.
|
||||
|
||||
Сервис должен быть доступен по HTTPS и IPv6: Яндекс Формы отправляют интеграционные
|
||||
запросы только с IPv6. При фильтрации входящего трафика разрешите сеть
|
||||
`2a02:6b8:c00::/40`. Подробности и настройка переменных — в
|
||||
[официальной инструкции Яндекс Форм](https://yandex.ru/support/forms/ru/send-request).
|
||||
|
||||
Проверка SQL и повторов на отдельной тестовой PostgreSQL:
|
||||
|
||||
```sh
|
||||
TEST_DATABASE_URL='postgres://...' go test ./internal/repos/teams_repo -run TestRegistrationPostgres -count=1
|
||||
```
|
||||
|
||||
Тест создаёт и удаляет собственную схему; требуется право `CREATE SCHEMA`.
|
||||
|
||||
+8
-6
@@ -1453,12 +1453,14 @@ message GetScreenResultsRsp {
|
||||
}
|
||||
|
||||
message Team {
|
||||
int32 id = 1;
|
||||
string name = 2;
|
||||
string status = 3;
|
||||
string password = 4;
|
||||
int32 actions_count = 5;
|
||||
repeated Application applications = 6;
|
||||
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.
@@ -268,6 +268,7 @@ func main() {
|
||||
}
|
||||
|
||||
mainMux := http.NewServeMux()
|
||||
mainMux.Handle("POST /api/games/{id}/registrations", app.TeamRegistrationHandler(os.Getenv("YANDEX_FORMS_TOKEN"), gameService.RegisterTeam))
|
||||
|
||||
docsHandler := v5emb.New("API", "/swagger/doc.json", "/docs/")
|
||||
mainMux.Handle("/docs/", docsHandler)
|
||||
|
||||
@@ -4019,6 +4019,13 @@
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/evening_detective_serverApplication"
|
||||
}
|
||||
},
|
||||
"captainEmail": {
|
||||
"type": "string"
|
||||
},
|
||||
"participantsCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
YANDEX_FORMS_TOKEN: ${YANDEX_FORMS_TOKEN:-}
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/evening_detective_server?sslmode=disable"
|
||||
SMTP_HOST: mail.crabs-games.art
|
||||
SMTP_PORT: 465
|
||||
|
||||
@@ -19,6 +19,10 @@ func mapGame(
|
||||
endedAt = timestamppb.New(*o.EndedAt)
|
||||
}
|
||||
|
||||
teams := make([]*proto.Team, 0, len(o.Teams))
|
||||
for _, team := range o.Teams {
|
||||
teams = append(teams, mapTeamDetails(team))
|
||||
}
|
||||
return &proto.Game{
|
||||
Id: int32(o.ID),
|
||||
Name: o.Name,
|
||||
@@ -27,7 +31,7 @@ func mapGame(
|
||||
Status: o.Status,
|
||||
Format: o.Format,
|
||||
Scenario: mapScenario(o.Scenario),
|
||||
Teams: mapTeams(o.Teams),
|
||||
Teams: teams,
|
||||
StartedAt: startedAt,
|
||||
EndedAt: endedAt,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ func mapTeam(
|
||||
}
|
||||
}
|
||||
|
||||
func mapTeamDetails(o *game_service.Team) *proto.Team {
|
||||
team := mapTeam(o)
|
||||
team.CaptainEmail = o.CaptainEmail
|
||||
team.ParticipantsCount = o.ParticipantsCount
|
||||
return team
|
||||
}
|
||||
|
||||
func mapTeams(
|
||||
o []*game_service.Team,
|
||||
) []*proto.Team {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
)
|
||||
|
||||
// TeamRegistrationHandler принимает webhook Яндекс Форм отдельно от пользовательского JWT.
|
||||
func TeamRegistrationHandler(token string, register func(context.Context, int, string, game_service.Registration) (int, error)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
fail := func(code int, message string) {
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
if token == "" {
|
||||
fail(http.StatusServiceUnavailable, "Интеграция регистрации не настроена")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) != 1 {
|
||||
fail(http.StatusUnauthorized, "Неверный токен интеграции")
|
||||
return
|
||||
}
|
||||
gameID, err := strconv.ParseInt(r.PathValue("id"), 10, 32)
|
||||
deliveryID := r.Header.Get("X-Delivery-Id")
|
||||
if err != nil || gameID <= 0 || deliveryID == "" || len(deliveryID) > 255 {
|
||||
fail(http.StatusBadRequest, "Требуются ID игры и заголовок X-Delivery-Id (до 255 байт)")
|
||||
return
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
var registration game_service.Registration
|
||||
if err := decoder.Decode(®istration); err != nil {
|
||||
fail(http.StatusBadRequest, "Некорректный JSON регистрации")
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
fail(http.StatusBadRequest, "Ожидается один JSON-объект")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
|
||||
defer cancel()
|
||||
id, err := register(ctx, int(gameID), deliveryID, registration)
|
||||
switch {
|
||||
case errors.Is(err, game_service.ErrInvalidRegistration):
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, teams_repo.ErrRegistrationClosed):
|
||||
fail(http.StatusConflict, err.Error())
|
||||
case err != nil:
|
||||
fail(http.StatusInternalServerError, "Не удалось зарегистрировать команду")
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]int{"id": id})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"evening_detective_server/internal/repos/teams_repo"
|
||||
"evening_detective_server/internal/services/game_service"
|
||||
)
|
||||
|
||||
func TestTeamRegistrationHandler(t *testing.T) {
|
||||
valid := `{"name":"Команда","email":"captain@example.org","participantsCount":5}`
|
||||
for _, tc := range []struct {
|
||||
name, token, auth, gameID, deliveryID, body string
|
||||
serviceErr error
|
||||
want int
|
||||
called bool
|
||||
}{
|
||||
{"success", "secret", "Bearer secret", "42", "delivery", valid, nil, 200, true},
|
||||
{"disabled", "", "Bearer secret", "42", "delivery", valid, nil, 503, false},
|
||||
{"no token", "secret", "", "42", "delivery", valid, nil, 401, false},
|
||||
{"wrong token", "secret", "Bearer wrong", "42", "delivery", valid, nil, 401, false},
|
||||
{"bad game", "secret", "Bearer secret", "0", "delivery", valid, nil, 400, false},
|
||||
{"no delivery", "secret", "Bearer secret", "42", "", valid, nil, 400, false},
|
||||
{"malformed", "secret", "Bearer secret", "42", "delivery", `{`, nil, 400, false},
|
||||
{"extra object", "secret", "Bearer secret", "42", "delivery", valid + ` {}`, nil, 400, false},
|
||||
{"unknown field", "secret", "Bearer secret", "42", "delivery", `{"extra":1}`, nil, 400, false},
|
||||
{"too large", "secret", "Bearer secret", "42", "delivery", `{"name":"` + strings.Repeat("a", 17000) + `"}`, nil, 400, false},
|
||||
{"invalid fields", "secret", "Bearer secret", "42", "delivery", valid, game_service.ErrInvalidRegistration, 400, true},
|
||||
{"closed", "secret", "Bearer secret", "42", "delivery", valid, teams_repo.ErrRegistrationClosed, 409, true},
|
||||
{"internal", "secret", "Bearer secret", "42", "delivery", valid, errors.New("private database detail"), 500, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
called := false
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /api/games/{id}/registrations", TeamRegistrationHandler(tc.token, func(ctx context.Context, gameID int, deliveryID string, registration game_service.Registration) (int, error) {
|
||||
called = true
|
||||
if gameID != 42 || deliveryID != "delivery" || registration.Name != "Команда" || registration.Email != "captain@example.org" || registration.ParticipantsCount != 5 {
|
||||
t.Fatal("incorrect registration passed to service")
|
||||
}
|
||||
if _, ok := ctx.Deadline(); !ok {
|
||||
t.Fatal("missing deadline")
|
||||
}
|
||||
return 7, tc.serviceErr
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/games/"+tc.gameID+"/registrations", strings.NewReader(tc.body))
|
||||
req.Header.Set("Authorization", tc.auth)
|
||||
req.Header.Set("X-Delivery-Id", tc.deliveryID)
|
||||
res := httptest.NewRecorder()
|
||||
mux.ServeHTTP(res, req)
|
||||
if res.Code != tc.want || called != tc.called {
|
||||
t.Fatalf("status=%d called=%v body=%s", res.Code, called, res.Body.String())
|
||||
}
|
||||
if tc.want == 200 && res.Body.String() != "{\"id\":7}\n" {
|
||||
t.Fatal(res.Body.String())
|
||||
}
|
||||
if strings.Contains(res.Body.String(), "private database detail") {
|
||||
t.Fatal("private error exposed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptainContactOnlyInGameDetails(t *testing.T) {
|
||||
team := &game_service.Team{ID: 1, CaptainEmail: "captain@example.org", ParticipantsCount: 5}
|
||||
details := mapGame(&game_service.Game{Teams: []*game_service.Team{team}})
|
||||
if details.Teams[0].CaptainEmail != team.CaptainEmail || details.Teams[0].ParticipantsCount != 5 {
|
||||
t.Fatal("registration details missing from organizer view")
|
||||
}
|
||||
if mapTeam(team).CaptainEmail != "" {
|
||||
t.Fatal("captain email exposed in shared team/results mapping")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package repos
|
||||
|
||||
type Team struct {
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
GameId int
|
||||
CaptainEmail string
|
||||
ParticipantsCount int32
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
GameId int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package teams_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestRegistrationPostgres(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("registration_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
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
_, err = pool.Exec(ctx, `CREATE TABLE games (id SERIAL PRIMARY KEY, status TEXT DEFAULT 'draft');
|
||||
CREATE TABLE teams (id SERIAL PRIMARY KEY, name VARCHAR(255), creator_id INTEGER,
|
||||
game_id INTEGER REFERENCES games(id), password TEXT, status TEXT DEFAULT 'register', created_at TIMESTAMP DEFAULT NOW());
|
||||
INSERT INTO games DEFAULT VALUES; INSERT INTO games DEFAULT VALUES;`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migration, err := os.ReadFile("../../../migrations/20260913130000_add_team_registration.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, strings.Split(string(migration), "-- +goose Down")[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewTeamsRepo(pool)
|
||||
type result struct {
|
||||
id int
|
||||
err error
|
||||
}
|
||||
results := make(chan result, 8)
|
||||
for range 8 {
|
||||
go func() {
|
||||
id, err := repo.RegisterTeam(ctx, 1, "delivery", "Team", "captain@example.org", 5, "abcdefgh")
|
||||
results <- result{id, err}
|
||||
}()
|
||||
}
|
||||
var id int
|
||||
for range 8 {
|
||||
got := <-results
|
||||
if got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
if id != 0 && got.id != id {
|
||||
t.Fatal("concurrent delivery created a duplicate")
|
||||
}
|
||||
id = got.id
|
||||
}
|
||||
team, err := repo.GetTeamByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if team.Name != "Team" || team.CaptainEmail != "captain@example.org" || team.ParticipantsCount != 5 || team.Password != "abcdefgh" {
|
||||
t.Fatalf("incorrect team: %+v", team)
|
||||
}
|
||||
var noUser bool
|
||||
if err := pool.QueryRow(ctx, "SELECT creator_id IS NULL FROM teams WHERE id=$1", id).Scan(&noUser); err != nil || !noUser {
|
||||
t.Fatalf("captain has a user: %v", err)
|
||||
}
|
||||
teams, err := repo.GetTeamsByGameID(ctx, 1)
|
||||
if err != nil || len(teams) != 1 || teams[0].CaptainEmail != team.CaptainEmail {
|
||||
t.Fatalf("teams=%v err=%v", teams, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "UPDATE games SET status='run' WHERE id=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := repo.RegisterTeam(ctx, 1, "delivery", "Changed", "other@example.org", 9, "different"); err != nil || got != id {
|
||||
t.Fatalf("retry after start: %d %v", got, err)
|
||||
}
|
||||
if _, err := repo.RegisterTeam(ctx, 1, "new", "Team", "captain@example.org", 5, "abcdefgh"); !errors.Is(err, ErrRegistrationClosed) {
|
||||
t.Fatalf("registration after start: %v", err)
|
||||
}
|
||||
if _, err := repo.RegisterTeam(ctx, 999, "new", "Team", "captain@example.org", 5, "abcdefgh"); !errors.Is(err, ErrRegistrationClosed) {
|
||||
t.Fatalf("missing game: %v", err)
|
||||
}
|
||||
if otherID, err := repo.RegisterTeam(ctx, 2, "delivery", "Team", "captain@example.org", 5, "abcdefgh"); err != nil || otherID == id {
|
||||
t.Fatalf("other game: %d %v", otherID, err)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
captain_email,
|
||||
participants_count,
|
||||
status,
|
||||
password,
|
||||
game_id
|
||||
@@ -40,6 +42,8 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
|
||||
err := row.Scan(
|
||||
&team.ID,
|
||||
&team.Name,
|
||||
&team.CaptainEmail,
|
||||
&team.ParticipantsCount,
|
||||
&team.Status,
|
||||
&team.Password,
|
||||
&team.GameId,
|
||||
@@ -63,6 +67,8 @@ func (r *TeamsRepo) GetTeamsByGameID(
|
||||
`SELECT
|
||||
id,
|
||||
name,
|
||||
captain_email,
|
||||
participants_count,
|
||||
status,
|
||||
password
|
||||
FROM teams
|
||||
@@ -81,6 +87,8 @@ func (r *TeamsRepo) GetTeamsByGameID(
|
||||
err := rows.Scan(
|
||||
&team.ID,
|
||||
&team.Name,
|
||||
&team.CaptainEmail,
|
||||
&team.ParticipantsCount,
|
||||
&team.Status,
|
||||
&team.Password,
|
||||
)
|
||||
@@ -155,3 +163,28 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamId int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
|
||||
|
||||
// RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы.
|
||||
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (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)
|
||||
if err == nil {
|
||||
return id, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
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'
|
||||
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)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrRegistrationClosed
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ErrInvalidRegistration = errors.New("Укажите название команды (до 255 символов), почту капитана и положительное количество участников")
|
||||
|
||||
type Registration struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ParticipantsCount int32 `json:"participantsCount"`
|
||||
}
|
||||
|
||||
func (r *Registration) Validate() error {
|
||||
r.Name = strings.TrimSpace(r.Name)
|
||||
r.Email = strings.TrimSpace(r.Email)
|
||||
address, err := mail.ParseAddress(r.Email)
|
||||
if r.Name == "" || !utf8.ValidString(r.Name) || strings.ContainsRune(r.Name, 0) || utf8.RuneCountInString(r.Name) > 255 ||
|
||||
err != nil || address.Address != r.Email || len(r.Email) > 254 || r.ParticipantsCount <= 0 {
|
||||
return ErrInvalidRegistration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GameService) RegisterTeam(ctx context.Context, gameID int, deliveryID string, registration Registration) (int, error) {
|
||||
if err := registration.Validate(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if gameID <= 0 || deliveryID == "" || len(deliveryID) > 255 || strings.ContainsRune(deliveryID, 0) {
|
||||
return 0, ErrInvalidRegistration
|
||||
}
|
||||
password, err := s.passwordGenerator.Generate()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.teamsRepo.RegisterTeam(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package game_service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistrationValidation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, email string
|
||||
count int32
|
||||
valid bool
|
||||
}{
|
||||
{" Команда ", " captain@example.org ", 5, true},
|
||||
{strings.Repeat("я", 255), "captain@example.org", 1, true},
|
||||
{"", "captain@example.org", 5, false},
|
||||
{strings.Repeat("я", 256), "captain@example.org", 5, false},
|
||||
{"team\x00", "captain@example.org", 5, false},
|
||||
{"team", "Captain <captain@example.org>", 5, false},
|
||||
{"team", "not-an-email", 5, false},
|
||||
{"team", "captain@example.org", 0, false},
|
||||
{"team", "captain@example.org", -1, false},
|
||||
} {
|
||||
r := Registration{Name: tc.name, Email: tc.email, ParticipantsCount: tc.count}
|
||||
if err := r.Validate(); (err == nil) != tc.valid {
|
||||
t.Fatalf("validation %q %q %d: %v", tc.name, tc.email, tc.count, err)
|
||||
}
|
||||
if tc.valid && (r.Name != strings.TrimSpace(tc.name) || r.Email != strings.TrimSpace(tc.email)) {
|
||||
t.Fatal("spaces not trimmed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package game_service
|
||||
import "evening_detective_server/internal/modules/storytelling"
|
||||
|
||||
type Team struct {
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
ActionsCount int
|
||||
Applications []*storytelling.Application
|
||||
CaptainEmail string
|
||||
ParticipantsCount int32
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
ActionsCount int
|
||||
Applications []*storytelling.Application
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ func mapTeam(
|
||||
o *repos.Team,
|
||||
) *Team {
|
||||
return &Team{
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Status: o.Status,
|
||||
Password: o.Password,
|
||||
ID: o.ID,
|
||||
CaptainEmail: o.CaptainEmail,
|
||||
ParticipantsCount: o.ParticipantsCount,
|
||||
Name: o.Name,
|
||||
Status: o.Status,
|
||||
Password: o.Password,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE teams
|
||||
ADD COLUMN captain_email TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN participants_count INTEGER NOT NULL DEFAULT 0 CHECK (participants_count >= 0),
|
||||
ADD COLUMN registration_delivery_id TEXT,
|
||||
ADD CONSTRAINT teams_registration_delivery_unique UNIQUE (game_id, registration_delivery_id);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE teams
|
||||
DROP CONSTRAINT teams_registration_delivery_unique,
|
||||
DROP COLUMN registration_delivery_id,
|
||||
DROP COLUMN participants_count,
|
||||
DROP COLUMN captain_email;
|
||||
+29
-11
@@ -6106,15 +6106,17 @@ func (x *GetScreenResultsRsp) GetTeams() []*TeamResult {
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"`
|
||||
ActionsCount int32 `protobuf:"varint,5,opt,name=actions_count,json=actionsCount,proto3" json:"actions_count,omitempty"`
|
||||
Applications []*Application `protobuf:"bytes,6,rep,name=applications,proto3" json:"applications,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"`
|
||||
ActionsCount int32 `protobuf:"varint,5,opt,name=actions_count,json=actionsCount,proto3" json:"actions_count,omitempty"`
|
||||
Applications []*Application `protobuf:"bytes,6,rep,name=applications,proto3" json:"applications,omitempty"`
|
||||
CaptainEmail string `protobuf:"bytes,7,opt,name=captain_email,json=captainEmail,proto3" json:"captain_email,omitempty"`
|
||||
ParticipantsCount int32 `protobuf:"varint,8,opt,name=participants_count,json=participantsCount,proto3" json:"participants_count,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Team) Reset() {
|
||||
@@ -6189,6 +6191,20 @@ func (x *Team) GetApplications() []*Application {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Team) GetCaptainEmail() string {
|
||||
if x != nil {
|
||||
return x.CaptainEmail
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Team) GetParticipantsCount() int32 {
|
||||
if x != nil {
|
||||
return x.ParticipantsCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetGameReq struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id int32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
@@ -8832,14 +8848,16 @@ const file_main_proto_rawDesc = "" +
|
||||
"\x13GetScreenResultsRsp\x12\x14\n" +
|
||||
"\x05error\x18\x01 \x01(\tR\x05error\x12M\n" +
|
||||
"\tquestions\x18\x02 \x03(\v2/.crabs.evening_detective_server.ResultsQuestionR\tquestions\x12@\n" +
|
||||
"\x05teams\x18\x03 \x03(\v2*.crabs.evening_detective_server.TeamResultR\x05teams\"\xd4\x01\n" +
|
||||
"\x05teams\x18\x03 \x03(\v2*.crabs.evening_detective_server.TeamResultR\x05teams\"\xa8\x02\n" +
|
||||
"\x04Team\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x05R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\x12\x1a\n" +
|
||||
"\bpassword\x18\x04 \x01(\tR\bpassword\x12#\n" +
|
||||
"\ractions_count\x18\x05 \x01(\x05R\factionsCount\x12O\n" +
|
||||
"\fapplications\x18\x06 \x03(\v2+.crabs.evening_detective_server.ApplicationR\fapplications\"\x1c\n" +
|
||||
"\fapplications\x18\x06 \x03(\v2+.crabs.evening_detective_server.ApplicationR\fapplications\x12#\n" +
|
||||
"\rcaptain_email\x18\a \x01(\tR\fcaptainEmail\x12-\n" +
|
||||
"\x12participants_count\x18\b \x01(\x05R\x11participantsCount\"\x1c\n" +
|
||||
"\n" +
|
||||
"GetGameReq\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\x05R\x02id\"\\\n" +
|
||||
|
||||
Reference in New Issue
Block a user