This commit is contained in:
2026-10-11 16:00:18 +07:00
parent 504b49f7cc
commit 030ca56900
77 changed files with 793 additions and 638 deletions
+6 -6
View File
@@ -32,7 +32,7 @@ func NewActionsRepo(pool *pgxpool.Pool) *ActionsRepo {
}
// AddAction stores an action for a team.
func (r *ActionsRepo) AddAction(ctx context.Context, teamID int, code string, viaMCP bool) (int, error) {
func (r *ActionsRepo) AddAction(ctx context.Context, teamID string, code string, viaMCP bool) (int, error) {
id := 0
err := r.pool.QueryRow(
ctx,
@@ -51,7 +51,7 @@ func (r *ActionsRepo) AddAction(ctx context.Context, teamID int, code string, vi
}
// GetActionsByTeamID returns actions for a team.
func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID int) ([]Action, error) {
func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID string) ([]Action, error) {
rows, err := r.pool.Query(
ctx,
`SELECT
@@ -87,7 +87,7 @@ func (r *ActionsRepo) GetActionsByTeamID(ctx context.Context, teamID int) ([]Act
}
// GetActionsCountByTeamIDs returns action counts for teams.
func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []int) (map[int]int, error) {
func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []string) (map[string]int, error) {
rows, err := r.pool.Query(
ctx,
`SELECT
@@ -103,9 +103,9 @@ func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []in
}
defer rows.Close()
res := make(map[int]int, len(teamIDs))
res := make(map[string]int, len(teamIDs))
for rows.Next() {
id := 0
var id string
count := 0
err := rows.Scan(
&id,
@@ -124,7 +124,7 @@ func (r *ActionsRepo) GetActionsCountByTeamIDs(ctx context.Context, teamIDs []in
}
// DeleteLastActionByTeamID removes the latest action for a team.
func (r *ActionsRepo) DeleteLastActionByTeamID(ctx context.Context, teamID int) error {
func (r *ActionsRepo) DeleteLastActionByTeamID(ctx context.Context, teamID string) error {
tag, err := r.pool.Exec(
ctx,
`DELETE FROM actions
+1 -1
View File
@@ -2,7 +2,7 @@
package repos
type TeamAnswer struct {
TeamID int
TeamID string
QuestionCode string
Text string
Score *int
+1 -1
View File
@@ -4,6 +4,6 @@ package repos
type Application struct {
Name string
Image string
TeamID int
TeamID string
FileType string
}
+6 -6
View File
@@ -29,7 +29,7 @@ func NewApplicationsRepo(pool *pgxpool.Pool) *ApplicationsRepo {
// AddApplication stores an application for a team.
func (r *ApplicationsRepo) AddApplication(
ctx context.Context,
teamID int,
teamID string,
name string,
image string,
fileType string,
@@ -55,9 +55,9 @@ func (r *ApplicationsRepo) AddApplication(
// GetApplicationsByTeamIDsAndState returns applications matching team IDs and state.
func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
ctx context.Context,
teamIDs []int,
teamIDs []string,
state string,
) (map[int][]*repos.Application, error) {
) (map[string][]*repos.Application, error) {
rows, err := r.pool.Query(
ctx,
`SELECT
@@ -75,7 +75,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
}
defer rows.Close()
res := make(map[int][]*repos.Application, len(teamIDs))
res := make(map[string][]*repos.Application, len(teamIDs))
for rows.Next() {
application := &repos.Application{}
err := rows.Scan(
@@ -99,7 +99,7 @@ func (r *ApplicationsRepo) GetApplicationsByTeamIDsAndState(
// UpdateApplicationStatus changes an application's status.
func (r *ApplicationsRepo) UpdateApplicationStatus(
ctx context.Context,
teamID int,
teamID string,
name string,
status string,
) error {
@@ -123,7 +123,7 @@ func (r *ApplicationsRepo) UpdateApplicationStatus(
}
// DeleteApplicationByTeamIDAndName removes an application by team and name.
func (r *ApplicationsRepo) DeleteApplicationByTeamIDAndName(ctx context.Context, teamID int, name string) error {
func (r *ApplicationsRepo) DeleteApplicationByTeamIDAndName(ctx context.Context, teamID string, name string) error {
tag, err := r.pool.Exec(
ctx,
`DELETE FROM applications
@@ -28,7 +28,7 @@ type exportFixture struct {
userID int
scenarioID int
gameID int
teamID int
teamID string
}
// newExportFixture uses real migrations in an isolated test schema; all game data uses repositories.
@@ -0,0 +1,43 @@
package game_exports_repo
import (
"context"
"fmt"
"testing"
"github.com/google/uuid"
)
func TestTeamIDsAreUniqueV7AcrossIndependentDatabases(t *testing.T) {
for _, name := range []string{"manual and public registration on three databases"} {
t.Run(name, func(t *testing.T) {
ctx := context.Background()
seen := map[string]bool{}
for database := range 3 {
fixture := newExportFixture(t)
for _, create := range []func() (string, error){
func() (string, error) {
return fixture.teams.AddTeam(ctx, fixture.userID, fixture.gameID, "Manual", "password")
},
func() (string, error) {
return fixture.teams.RegisterTeam(ctx, fixture.gameID, fmt.Sprint(database), "Public", "captain@example.test", 2, "password")
},
} {
id, err := create()
requireNoError(t, err)
parsed, err := uuid.Parse(id)
requireNoError(t, err)
if parsed.Version() != 7 || parsed.Variant() != uuid.RFC4122 || parsed.String() != id || seen[id] {
t.Fatalf("invalid or reused team UUID: %s", id)
}
seen[id] = true
team, err := fixture.teams.GetTeamByID(ctx, id)
requireNoError(t, err)
if team.ID != id || team.Password != "password" {
t.Fatal("team identity changed during storage")
}
}
}
})
}
}
+2 -14
View File
@@ -131,18 +131,6 @@ func (r *GameImportsRepo) Complete(ctx context.Context, id string, data []byte)
FROM games_id_seq
)
)),
setval('teams_id_seq', GREATEST(
(
SELECT
COALESCE(MAX(id), 1)
FROM teams
),
(
SELECT
last_value
FROM teams_id_seq
)
)),
setval('tickets_id_seq', GREATEST(
(
SELECT
@@ -291,7 +279,7 @@ func insertGameRelations(ctx context.Context, tx pgx.Tx, data []byte) error {
t.registration_delivery_id,
t.created_at AT TIME ZONE 'UTC'
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS t(
id integer, game_id integer, name text, status text, password text, captain_email text,
id uuid, game_id integer, name text, status text, password text, captain_email text,
participants_count integer, participation_status text, participation_source text,
participation_answered_at timestamptz, registration_delivery_id text, created_at timestamptz
)`,
@@ -344,7 +332,7 @@ func insertGameRelations(ctx context.Context, tx pgx.Tx, data []byte) error {
m.y,
m.hidden
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'map', 'null'::jsonb), '[]'::jsonb)) AS m(
game_id integer, kind text, item_id integer, x integer, y integer, hidden boolean
game_id integer, kind text, item_id text, x integer, y integer, hidden boolean
)`,
data,
)
+1 -1
View File
@@ -155,7 +155,7 @@ func checkConflicts(ctx context.Context, tx pgx.Tx, raw []byte) error {
SELECT
1
FROM teams t
JOIN jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS s(id integer) ON s.id = t.id
JOIN jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'teams', 'null'::jsonb), '[]'::jsonb)) AS s(id uuid) ON s.id = t.id
) OR EXISTS (
SELECT
1
+2 -2
View File
@@ -13,7 +13,7 @@ var ErrItemNotFound = errors.New("объект карты не найден")
type Item struct {
GameID int
Kind string
ItemID int
ItemID string
X int
Y int
Hidden bool
@@ -28,7 +28,7 @@ func NewGameMapRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID int) error {
func (r *Repo) Ensure(ctx context.Context, gameID int, kind string, itemID string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO game_map_items (game_id, kind, item_id)
VALUES ($1, $2, $3)
@@ -54,32 +54,3 @@ func TestTransactionRetriesOnlyTransientFailures(t *testing.T) {
})
}
}
func TestSequenceErrorPreservesCancellationAndCause(t *testing.T) {
for _, tc := range []struct {
name string
cancelParent, cancelAdvance bool
expected error
}{
{"parent cancelled", true, true, context.Canceled},
{"advance cancelled", false, true, ErrRetry},
{"driver failure", false, false, ErrConflict},
} {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
advanceCtx, advanceCancel := context.WithCancel(ctx)
defer advanceCancel()
if tc.cancelParent {
cancel()
}
if tc.cancelAdvance {
advanceCancel()
}
err := sequenceError(ctx, advanceCtx, ErrConflict)
if !errors.Is(err, tc.expected) {
t.Fatal(err)
}
})
}
}
+8 -57
View File
@@ -2,7 +2,6 @@ package game_returns_repo
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
@@ -16,7 +15,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
SELECT
1
FROM teams t
JOIN jsonb_to_recordset($2::jsonb->'teams') AS n(id integer) ON n.id = t.id
JOIN jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid) ON n.id = t.id
WHERE t.game_id <> $1
) OR EXISTS (
SELECT
@@ -49,15 +48,15 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
AND NOT EXISTS (
SELECT
1
FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer)
WHERE n.id = m.item_id
FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid)
WHERE n.id::text = m.item_id
)`,
`DELETE FROM teams t
WHERE t.game_id = $1
AND NOT EXISTS (
SELECT
1
FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id integer)
FROM jsonb_to_recordset($2::jsonb->'teams') AS n(id uuid)
WHERE n.id = t.id
)`,
`DELETE FROM tickets t
@@ -89,7 +88,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
n.registration_delivery_id,
n.created_at
FROM jsonb_to_recordset($2::jsonb->'teams') AS n(
id integer,
id uuid,
game_id integer,
name text,
status text,
@@ -153,7 +152,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
n.created_at
FROM jsonb_to_recordset($2::jsonb->'actions') AS n(
id integer,
team_id integer,
team_id uuid,
code text,
via_mcp boolean,
created_at timestamp
@@ -175,7 +174,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
n.created_at
FROM jsonb_to_recordset($2::jsonb->'applications') AS n(
id integer,
team_id integer,
team_id uuid,
name text,
image text,
file_type text,
@@ -197,7 +196,7 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
n.created_at,
n.updated_at
FROM jsonb_to_recordset($2::jsonb->'answers') AS n(
team_id integer,
team_id uuid,
question_code text,
answer text,
score integer,
@@ -226,57 +225,9 @@ func Write(ctx context.Context, tx pgx.Tx, gameID int, data []byte) error {
return err
}
}
// nextval is monotonic under concurrent inserts; setval can move a live sequence backwards.
// shortcut: huge venue ID gaps consume sequence values even on refusal; widen IDs before supporting such venues.
var maximum int
if err := tx.QueryRow(
ctx,
`SELECT COALESCE(max(id),0)
FROM teams`,
).Scan(&maximum); err != nil {
return err
}
if maximum >= 2147483647 {
return &ConflictError{Reason: "идентификатор команды достиг предела допустимого диапазона."}
}
advanceCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
for {
var id int
if err := tx.QueryRow(
advanceCtx,
`SELECT nextval('teams_id_seq')`,
).Scan(&id); err != nil {
return sequenceError(ctx, advanceCtx, err)
}
if id >= maximum {
break
}
if err := tx.QueryRow(
advanceCtx,
`SELECT max(nextval('teams_id_seq'))
FROM generate_series(1,$1::integer)`,
min(maximum-id, 4096),
).Scan(&id); err != nil {
return sequenceError(ctx, advanceCtx, err)
}
if id >= maximum {
break
}
}
return nil
}
func sequenceError(ctx, advanceCtx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if advanceCtx.Err() != nil {
return ErrRetry
}
return err
}
// RestoreStory isolates returned questions and materials from other games sharing the scenario.
func RestoreStory(ctx context.Context, tx pgx.Tx, gameID int, story []byte) (int, error) {
var scenarioID int
+2 -2
View File
@@ -26,7 +26,7 @@ type Review struct {
ID int
GameID int
GameName string
TeamID int
TeamID string
TeamName string
Rating int32
Difficulty int32
@@ -42,7 +42,7 @@ type Repo struct{ pool *pgxpool.Pool }
func NewGameReviewsRepo(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
// Create stores a review.
func (r *Repo) Create(ctx context.Context, gameID, teamID int, rating, difficulty int32, text string) (*Review, error) {
func (r *Repo) Create(ctx context.Context, gameID int, teamID string, rating, difficulty int32, text string) (*Review, error) {
review := &Review{}
err := r.pool.QueryRow(
ctx,
+1 -1
View File
@@ -8,7 +8,7 @@ type Team struct {
ParticipationEmailSent bool
CaptainEmail string
ParticipantsCount int32
ID int
ID string
Name string
Status string
Password string
+3 -3
View File
@@ -22,7 +22,7 @@ func NewTeamAnswersRepo(pool *pgxpool.Pool) *TeamAnswersRepo {
func (r *TeamAnswersRepo) UpsertAnswers(
ctx context.Context,
teamID int,
teamID string,
answers []repos.TeamAnswer,
) error {
for _, answer := range answers {
@@ -48,7 +48,7 @@ func (r *TeamAnswersRepo) UpsertAnswers(
// GetAnswersByTeamID returns answers submitted by a team.
func (r *TeamAnswersRepo) GetAnswersByTeamID(
ctx context.Context,
teamID int,
teamID string,
) ([]*repos.TeamAnswer, error) {
rows, err := r.pool.Query(
ctx,
@@ -133,7 +133,7 @@ func (r *TeamAnswersRepo) GetAnswersByGameID(
func (r *TeamAnswersRepo) SetAnswerScore(
ctx context.Context,
teamID int,
teamID string,
questionCode string,
score *int,
) error {
+22
View File
@@ -0,0 +1,22 @@
package teams_repo
import (
"context"
"errors"
"testing"
)
func TestTeamOperationsRejectInvalidIDs(t *testing.T) {
for _, id := range []string{"", "12", "invalid", "00000000-0000-0000-0000-000000000000", "019A1234-5678-7ABC-8DEF-000000000001", "019a123456787abc8def000000000001"} {
t.Run(id, func(t *testing.T) {
repo := NewTeamsRepo(nil)
ctx := context.Background()
_, err := repo.GetTeamByID(ctx, id)
for _, result := range []error{err, repo.UpdateTeam(ctx, id, "Team"), repo.DeleteTeam(ctx, id)} {
if !errors.Is(result, ErrTeamNotFound) {
t.Fatal(result)
}
}
})
}
}
+6 -6
View File
@@ -26,7 +26,7 @@ const maxReviewReminderAttempts = 8
// Participation describes a team's participation state.
type Participation struct {
TeamID int
TeamID string
TeamName string
GameName string
StartAt time.Time
@@ -39,7 +39,7 @@ type Participation struct {
// ReviewReminder describes a pending review reminder.
type ReviewReminder struct {
TeamID int
TeamID string
TeamName string
GameID int
GameName string
@@ -130,7 +130,7 @@ func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation
}
// SetParticipationToken stores a participation token.
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error {
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id string, token string) error {
hash, err := tokenHash(token)
if err != nil {
return err
@@ -140,7 +140,7 @@ func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token str
}
// ConfirmParticipationByFirstAction confirms participation after the first action.
func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID int) error {
func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamID string) error {
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_status='confirmed',
participation_source='first_action', participation_answered_at=NOW()
WHERE id=$1 AND participation_status='pending'`, teamID)
@@ -148,7 +148,7 @@ func (r *TeamsRepo) ConfirmParticipationByFirstAction(ctx context.Context, teamI
}
// FinishParticipationEmail completes a participation email job.
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error {
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id string, attempt int, sendErr error) error {
message := ""
if sendErr != nil {
message = sendErr.Error()
@@ -216,7 +216,7 @@ func (r *TeamsRepo) ClaimReviewReminder(ctx context.Context) (*ReviewReminder, e
}
// FinishReviewReminder completes a review reminder job.
func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id, attempt int, sendErr error) error {
func (r *TeamsRepo) FinishReviewReminder(ctx context.Context, id string, attempt int, sendErr error) error {
message := ""
if sendErr != nil {
message = sendErr.Error()
@@ -45,7 +45,7 @@ func TestRegistrationPostgres(t *testing.T) {
}
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,
CREATE TABLE teams (id UUID 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 {
@@ -67,7 +67,7 @@ func TestRegistrationPostgres(t *testing.T) {
}
repo := NewTeamsRepo(pool)
type result struct {
id int
id string
err error
}
results := make(chan result, 8)
@@ -77,13 +77,13 @@ func TestRegistrationPostgres(t *testing.T) {
results <- result{id, err}
}()
}
var id int
var id string
for range 8 {
got := <-results
if got.err != nil {
t.Fatal(got.err)
}
if id != 0 && got.id != id {
if id != "" && got.id != id {
t.Fatal("concurrent delivery created a duplicate")
}
id = got.id
@@ -107,7 +107,7 @@ func TestRegistrationPostgres(t *testing.T) {
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)
t.Fatalf("retry after start: %s %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)
@@ -116,7 +116,7 @@ func TestRegistrationPostgres(t *testing.T) {
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)
t.Fatalf("other game: %s %v", otherID, err)
}
for _, email := range []string{"", "manual@example.org"} {
manualID, err := repo.AddTeamWithParticipation(ctx, 1, 2, "Manual", "password", email, 0, false)
+101 -27
View File
@@ -5,6 +5,7 @@ import (
"errors"
"evening_detective_server/internal/repos"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,7 +28,10 @@ func NewTeamsRepo(pool *pgxpool.Pool) *TeamsRepo {
}
// GetTeamByID returns a team by ID.
func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error) {
func (r *TeamsRepo) GetTeamByID(ctx context.Context, id string) (*repos.Team, error) {
if !validTeamID(id) {
return nil, ErrTeamNotFound
}
team := &repos.Team{}
row := r.pool.QueryRow(
ctx,
@@ -36,7 +40,10 @@ 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,
participation_status,
participation_source,
participation_email_error,
participation_sent_at IS NOT NULL,
status,
password,
game_id
@@ -76,7 +83,10 @@ func (r *TeamsRepo) GetTeamsByGameID(
name,
captain_email,
participants_count,
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
participation_status,
participation_source,
participation_email_error,
participation_sent_at IS NOT NULL,
status,
password
FROM teams
@@ -120,22 +130,33 @@ func (r *TeamsRepo) AddTeam(
gameID int,
name string,
password string,
) (int, error) {
) (string, error) {
return r.AddTeamWithParticipation(ctx, creatorID, gameID, name, password, "", 0, false)
}
// AddTeamWithParticipation creates a team with participation data.
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (int, error) {
id := 0
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gameID int, name, password, captainEmail string, participantsCount int32, autoConfirm bool) (string, error) {
id, err := uuid.NewV7()
if err != nil {
return "", err
}
var teamID string
status, source := "pending", ""
if autoConfirm {
status, source = "confirmed", "automatic"
}
err := r.pool.QueryRow(
err = r.pool.QueryRow(
ctx,
`INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at, captain_email, participants_count)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8)
RETURNING id`,
`INSERT INTO teams (
id, name, creator_id, game_id, password, participation_status,
participation_source, participation_answered_at, captain_email, participants_count
)
VALUES (
$9, $1, $2, $3, $4, $5, $6,
CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END, $7, $8
)
RETURNING
id`,
name,
creatorID,
gameID,
@@ -144,16 +165,20 @@ func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorID, gam
source,
captainEmail,
participantsCount,
).Scan(&id)
id.String(),
).Scan(&teamID)
if err != nil {
return 0, err
return "", err
}
return id, nil
return teamID, nil
}
// UpdateTeam changes a team's name.
func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) error {
func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID string, name string) error {
if !validTeamID(teamID) {
return ErrTeamNotFound
}
tag, err := r.pool.Exec(
ctx,
`UPDATE teams
@@ -173,7 +198,10 @@ func (r *TeamsRepo) UpdateTeam(ctx context.Context, teamID int, name string) err
}
// DeleteTeam removes a team.
func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error {
func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID string) error {
if !validTeamID(teamID) {
return ErrTeamNotFound
}
tag, err := r.pool.Exec(
ctx,
`DELETE FROM teams
@@ -192,32 +220,73 @@ func (r *TeamsRepo) DeleteTeam(ctx context.Context, teamID int) error {
// ErrRegistrationClosed indicates that team registration is unavailable.
var ErrRegistrationClosed = errors.New("игра не найдена или регистрация уже закрыта")
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) {
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (string, error) {
return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false)
}
// RegisterTeamWithParticipation registers a team and its participation data.
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)
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (string, error) {
var id string
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
return "", err
}
newID, err := uuid.NewV7()
if err != nil {
return "", 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, 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, status, source).Scan(&id)
err = r.pool.QueryRow(
ctx,
`INSERT INTO teams (
id, game_id, registration_delivery_id, name, captain_email,
participants_count, password, participation_status,
participation_source, participation_answered_at
)
SELECT
$9,
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,
status,
source,
newID.String(),
).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrRegistrationClosed
return "", ErrRegistrationClosed
}
return id, err
}
@@ -232,3 +301,8 @@ func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*
}
return active, err
}
func validTeamID(id string) bool {
parsed, err := uuid.Parse(id)
return err == nil && parsed != uuid.Nil && parsed.String() == id
}