generated from VLADIMIR/template
add
This commit is contained in:
+11
-7
@@ -1,11 +1,15 @@
|
||||
package repos
|
||||
|
||||
type Team struct {
|
||||
CaptainEmail string
|
||||
ParticipantsCount int32
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
GameId int
|
||||
ParticipationStatus string
|
||||
ParticipationSource string
|
||||
ParticipationEmailError string
|
||||
ParticipationEmailSent bool
|
||||
CaptainEmail string
|
||||
ParticipantsCount int32
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
Password string
|
||||
GameId int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package teams_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrParticipationLink = errors.New("Ссылка недействительна")
|
||||
var ErrParticipationClosed = errors.New("Приём ответов закрыт")
|
||||
var ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено")
|
||||
|
||||
const maxParticipationEmailAttempts = 8
|
||||
|
||||
type Participation struct {
|
||||
TeamID int
|
||||
TeamName string
|
||||
GameName string
|
||||
StartAt time.Time
|
||||
ParticipantsCount int32
|
||||
Status string
|
||||
Closed bool
|
||||
Email string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
func tokenHash(token string) ([]byte, error) {
|
||||
if len(token) != 64 {
|
||||
return nil, ErrParticipationLink
|
||||
}
|
||||
raw, err := hex.DecodeString(token)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, ErrParticipationLink
|
||||
}
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) GetParticipation(ctx context.Context, token string) (*Participation, error) {
|
||||
hash, err := tokenHash(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &Participation{}
|
||||
err = r.pool.QueryRow(ctx, `SELECT t.id, t.name, g.name, g.start_at, t.participants_count, t.participation_status,
|
||||
g.status <> 'draft' OR g.start_at <= (NOW() AT TIME ZONE 'UTC')
|
||||
FROM teams t JOIN games g ON g.id=t.game_id WHERE t.participation_token_hash=$1`, hash).Scan(
|
||||
&p.TeamID, &p.TeamName, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Status, &p.Closed)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrParticipationLink
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
|
||||
if answer != "confirmed" && answer != "cancelled" {
|
||||
return nil, errors.New("Выберите подтверждение или отмену участия")
|
||||
}
|
||||
hash, err := tokenHash(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The condition is rechecked after a concurrent answer releases its row lock.
|
||||
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
|
||||
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
|
||||
UPDATE teams t SET participation_status=$2,
|
||||
participation_source='captain', participation_answered_at=NOW()
|
||||
FROM eligible_game g WHERE g.id=t.game_id AND t.participation_token_hash=$1
|
||||
AND t.participation_status='pending'`, hash, answer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := r.GetParticipation(ctx, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Status == answer {
|
||||
return p, nil
|
||||
}
|
||||
if p.Status != "pending" {
|
||||
return p, ErrParticipationFinal
|
||||
}
|
||||
return p, ErrParticipationClosed
|
||||
}
|
||||
|
||||
// A five-minute lease survives restarts and keeps concurrent workers from sending the same job.
|
||||
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
|
||||
p := &Participation{}
|
||||
err := r.pool.QueryRow(ctx, `WITH candidate AS (
|
||||
SELECT t.id FROM teams t JOIN games g ON g.id=t.game_id
|
||||
WHERE t.participation_status='pending' AND t.captain_email<>'' AND t.participation_sent_at IS NULL
|
||||
AND t.participation_attempts < $1
|
||||
AND (t.participation_next_attempt_at IS NULL OR t.participation_next_attempt_at <= NOW())
|
||||
AND g.status='draft' AND g.start_at > (NOW() AT TIME ZONE 'UTC')
|
||||
AND g.start_at <= (NOW() AT TIME ZONE 'UTC') + INTERVAL '24 hours'
|
||||
ORDER BY t.participation_next_attempt_at NULLS FIRST, t.id
|
||||
FOR UPDATE OF t SKIP LOCKED LIMIT 1
|
||||
) UPDATE teams t SET participation_next_attempt_at=NOW()+INTERVAL '5 minutes',
|
||||
participation_attempts=t.participation_attempts+1, participation_email_error=''
|
||||
FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id
|
||||
|
||||
RETURNING t.id,t.name,t.captain_email,g.name,g.start_at,t.participants_count,t.participation_attempts`, maxParticipationEmailAttempts).Scan(
|
||||
&p.TeamID, &p.TeamName, &p.Email, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Attempts)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error {
|
||||
hash, err := tokenHash(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `UPDATE teams SET participation_token_hash=$2 WHERE id=$1`, id, hash)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error {
|
||||
message := ""
|
||||
if sendErr != nil {
|
||||
message = sendErr.Error()
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_email_error=$3,
|
||||
participation_sent_at=CASE WHEN $3='' THEN NOW() ELSE participation_sent_at END
|
||||
WHERE id=$1 AND participation_attempts=$2`, id, attempt, message)
|
||||
return err
|
||||
}
|
||||
@@ -58,6 +58,13 @@ func TestRegistrationPostgres(t *testing.T) {
|
||||
if _, err := pool.Exec(ctx, strings.Split(string(migration), "-- +goose Down")[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
participationMigration, err := os.ReadFile("../../../migrations/20260915120000_add_team_participation.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, strings.Split(string(participationMigration), "-- +goose Down")[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewTeamsRepo(pool)
|
||||
type result struct {
|
||||
id int
|
||||
|
||||
@@ -32,6 +32,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
|
||||
name,
|
||||
captain_email,
|
||||
participants_count,
|
||||
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
|
||||
status,
|
||||
password,
|
||||
game_id
|
||||
@@ -44,6 +45,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
|
||||
&team.Name,
|
||||
&team.CaptainEmail,
|
||||
&team.ParticipantsCount,
|
||||
&team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent,
|
||||
&team.Status,
|
||||
&team.Password,
|
||||
&team.GameId,
|
||||
@@ -69,6 +71,7 @@ func (r *TeamsRepo) GetTeamsByGameID(
|
||||
name,
|
||||
captain_email,
|
||||
participants_count,
|
||||
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
|
||||
status,
|
||||
password
|
||||
FROM teams
|
||||
@@ -89,6 +92,7 @@ func (r *TeamsRepo) GetTeamsByGameID(
|
||||
&team.Name,
|
||||
&team.CaptainEmail,
|
||||
&team.ParticipantsCount,
|
||||
&team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent,
|
||||
&team.Status,
|
||||
&team.Password,
|
||||
)
|
||||
@@ -111,16 +115,26 @@ func (r *TeamsRepo) AddTeam(
|
||||
name string,
|
||||
password string,
|
||||
) (int, error) {
|
||||
return r.AddTeamWithParticipation(ctx, creatorId, gameId, name, password, false)
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorId, gameId int, name, password string, autoConfirm bool) (int, error) {
|
||||
id := 0
|
||||
status, source := "pending", ""
|
||||
if autoConfirm {
|
||||
status, source = "confirmed", "automatic"
|
||||
}
|
||||
err := r.pool.QueryRow(
|
||||
ctx,
|
||||
`INSERT INTO teams (name, creator_id, game_id, password)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END)
|
||||
RETURNING id`,
|
||||
name,
|
||||
creatorId,
|
||||
gameId,
|
||||
password,
|
||||
status,
|
||||
source,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
@@ -168,6 +182,10 @@ var ErrRegistrationClosed = errors.New("Игра не найдена или ре
|
||||
|
||||
// RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы.
|
||||
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) {
|
||||
return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false)
|
||||
}
|
||||
|
||||
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) {
|
||||
var id int
|
||||
// Повтор уже принятой доставки успешен и после старта игры.
|
||||
err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id)
|
||||
@@ -177,14 +195,30 @@ func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, na
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, err
|
||||
}
|
||||
status, source := "pending", ""
|
||||
if autoConfirm {
|
||||
status, source = "confirmed", "automatic"
|
||||
}
|
||||
err = r.pool.QueryRow(ctx, `
|
||||
INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password)
|
||||
SELECT id, $2, $3, $4, $5, $6 FROM games WHERE id = $1 AND status = 'draft'
|
||||
INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password, participation_status, participation_source, participation_answered_at)
|
||||
SELECT id, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END FROM games WHERE id = $1 AND status = 'draft'
|
||||
ON CONFLICT (game_id, registration_delivery_id)
|
||||
DO UPDATE SET registration_delivery_id = EXCLUDED.registration_delivery_id
|
||||
RETURNING id`, gameID, deliveryID, name, email, participantsCount, password).Scan(&id)
|
||||
RETURNING id`, gameID, deliveryID, name, email, participantsCount, password, status, source).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrRegistrationClosed
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetActiveTeamsByGameID excludes cancelled registrations from gameplay exports and results.
|
||||
func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) {
|
||||
teams, err := r.GetTeamsByGameID(ctx, gameID)
|
||||
active := teams[:0]
|
||||
for _, team := range teams {
|
||||
if team.ParticipationStatus != "cancelled" {
|
||||
active = append(active, team)
|
||||
}
|
||||
}
|
||||
return active, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user