generated from VLADIMIR/template
up
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
package repos
|
||||
|
||||
type TeamAnswer struct {
|
||||
TeamID int
|
||||
TeamID string
|
||||
QuestionCode string
|
||||
Text string
|
||||
Score *int
|
||||
|
||||
@@ -4,6 +4,6 @@ package repos
|
||||
type Application struct {
|
||||
Name string
|
||||
Image string
|
||||
TeamID int
|
||||
TeamID string
|
||||
FileType string
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,7 @@ type Team struct {
|
||||
ParticipationEmailSent bool
|
||||
CaptainEmail string
|
||||
ParticipantsCount int32
|
||||
ID int
|
||||
ID string
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user