diff --git a/.env.example b/.env.example index beb0685..3d70186 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 577b459..ea8ee9a 100644 --- a/README.md +++ b/README.md @@ -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 `. +- Заголовок `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`. diff --git a/api/main.proto b/api/main.proto index 769a740..1ecbaa9 100644 --- a/api/main.proto +++ b/api/main.proto @@ -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 { diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 9079ecc..c08cbe1 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 6668d8d..63fda05 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -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) diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 7e7c14f..f0d5772 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -4019,6 +4019,13 @@ "type": "object", "$ref": "#/definitions/evening_detective_serverApplication" } + }, + "captainEmail": { + "type": "string" + }, + "participantsCount": { + "type": "integer", + "format": "int32" } } }, diff --git a/docker-compose.yml b/docker-compose.yml index 5905286..64648a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/app/game_mapper.go b/internal/app/game_mapper.go index d3aa7d7..4d0f4d4 100644 --- a/internal/app/game_mapper.go +++ b/internal/app/game_mapper.go @@ -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, } diff --git a/internal/app/team_mapper.go b/internal/app/team_mapper.go index 8cda6d5..0b7aac6 100644 --- a/internal/app/team_mapper.go +++ b/internal/app/team_mapper.go @@ -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 { diff --git a/internal/app/team_registration.go b/internal/app/team_registration.go new file mode 100644 index 0000000..83446ec --- /dev/null +++ b/internal/app/team_registration.go @@ -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}) + } + }) +} diff --git a/internal/app/team_registration_test.go b/internal/app/team_registration_test.go new file mode 100644 index 0000000..e8133d9 --- /dev/null +++ b/internal/app/team_registration_test.go @@ -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") + } +} diff --git a/internal/repos/team.go b/internal/repos/team.go index 87cdb69..829b70a 100644 --- a/internal/repos/team.go +++ b/internal/repos/team.go @@ -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 } diff --git a/internal/repos/teams_repo/registration_test.go b/internal/repos/teams_repo/registration_test.go new file mode 100644 index 0000000..0590f36 --- /dev/null +++ b/internal/repos/teams_repo/registration_test.go @@ -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) + } +} diff --git a/internal/repos/teams_repo/repo.go b/internal/repos/teams_repo/repo.go index 15186a2..0002722 100644 --- a/internal/repos/teams_repo/repo.go +++ b/internal/repos/teams_repo/repo.go @@ -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 +} diff --git a/internal/services/game_service/registration.go b/internal/services/game_service/registration.go new file mode 100644 index 0000000..8a1ed8d --- /dev/null +++ b/internal/services/game_service/registration.go @@ -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) +} diff --git a/internal/services/game_service/registration_test.go b/internal/services/game_service/registration_test.go new file mode 100644 index 0000000..1f587b5 --- /dev/null +++ b/internal/services/game_service/registration_test.go @@ -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 ", 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") + } + } +} diff --git a/internal/services/game_service/team.go b/internal/services/game_service/team.go index 2f82090..98cff05 100644 --- a/internal/services/game_service/team.go +++ b/internal/services/game_service/team.go @@ -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 } diff --git a/internal/services/game_service/team_mapper.go b/internal/services/game_service/team_mapper.go index 0cb9500..0e62436 100644 --- a/internal/services/game_service/team_mapper.go +++ b/internal/services/game_service/team_mapper.go @@ -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, } } diff --git a/migrations/20260913130000_add_team_registration.sql b/migrations/20260913130000_add_team_registration.sql new file mode 100644 index 0000000..6bd6637 --- /dev/null +++ b/migrations/20260913130000_add_team_registration.sql @@ -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; diff --git a/proto/main.pb.go b/proto/main.pb.go index dcf9c17..9ebb9a0 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -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" +