This commit is contained in:
2026-09-16 02:44:44 +07:00
parent 27db2067e3
commit 0e42278b36
25 changed files with 1781 additions and 424 deletions
+52
View File
@@ -0,0 +1,52 @@
package app
import (
"context"
"errors"
"evening_detective_server/internal/repos/teams_repo"
"evening_detective_server/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/timestamppb"
)
func mapParticipationResponse(p *teams_repo.Participation) *proto.ParticipationRsp {
result := &proto.ParticipationRsp{}
if p != nil {
result.TeamName = p.TeamName
result.GameName = p.GameName
result.StartAt = timestamppb.New(p.StartAt)
result.ParticipantsCount = p.ParticipantsCount
result.Status = p.Status
result.Closed = p.Closed
}
return result
}
func participationError(err error) string {
if errors.Is(err, teams_repo.ErrParticipationLink) || errors.Is(err, teams_repo.ErrParticipationFinal) || errors.Is(err, teams_repo.ErrParticipationClosed) {
return err.Error()
}
return "Не удалось обработать ответ. Попробуйте позже."
}
func (s *server) GetParticipation(ctx context.Context, req *proto.GetParticipationReq) (*proto.ParticipationRsp, error) {
_ = grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store"))
p, err := s.gameService.GetParticipation(ctx, req.Token)
result := mapParticipationResponse(p)
if err != nil {
result.Error = participationError(err)
}
return result, nil
}
func (s *server) AnswerParticipation(ctx context.Context, req *proto.AnswerParticipationReq) (*proto.ParticipationRsp, error) {
_ = grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store"))
if req.Answer != "confirmed" && req.Answer != "cancelled" {
return &proto.ParticipationRsp{Error: "Выберите подтверждение или отмену участия"}, nil
}
p, err := s.gameService.AnswerParticipation(ctx, req.Token, req.Answer)
result := mapParticipationResponse(p)
if err != nil {
result.Error = participationError(err)
}
return result, nil
}
+6
View File
@@ -203,6 +203,9 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro
return nil, status.Error(codes.NotFound, "игра не найдена")
}
for _, team := range game.Teams {
if team.ParticipationStatus == "cancelled" {
continue
}
if err := s.mapRepo.Ensure(ctx, int(req.GameId), "team", team.ID); err != nil {
return nil, status.Error(codes.Internal, "не удалось сохранить карту")
}
@@ -222,6 +225,9 @@ func (s *server) GetGameMap(ctx context.Context, req *proto.GetGameMapReq) (*pro
}
teamNames := map[int]string{}
for _, team := range game.Teams {
if team.ParticipationStatus == "cancelled" {
continue
}
teamNames[team.ID] = team.Name
}
screenStates := map[int]*proto.ScreenState{}
+4
View File
@@ -20,6 +20,10 @@ func mapTeam(
func mapTeamDetails(o *game_service.Team) *proto.Team {
team := mapTeam(o)
team.ParticipationStatus = o.ParticipationStatus
team.ParticipationSource = o.ParticipationSource
team.ParticipationEmailError = o.ParticipationEmailError
team.ParticipationEmailSent = o.ParticipationEmailSent
team.CaptainEmail = o.CaptainEmail
team.ParticipantsCount = o.ParticipantsCount
return team
+11 -7
View File
@@ -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
}
+129
View File
@@ -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
+39 -5
View File
@@ -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
}
@@ -35,7 +35,9 @@ func TestFinishedTeamStoryPostgres(t *testing.T) {
CREATE TEMP TABLE teams (id INT, name TEXT, captain_email TEXT, participants_count INT,
status TEXT, password TEXT, game_id INT);
INSERT INTO games VALUES (1, 'Game', '', NOW(), 'online', 'finish', 1, NULL, NULL);
INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1);`)
INSERT INTO teams VALUES (1, 'Team', '', 1, 'register', 'secret', 1);
ALTER TABLE teams ADD COLUMN participation_status TEXT DEFAULT 'pending', ADD COLUMN participation_source TEXT DEFAULT '',
ADD COLUMN participation_email_error TEXT DEFAULT '', ADD COLUMN participation_sent_at TIMESTAMPTZ;`)
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,104 @@
package game_service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html"
"log"
"net/url"
"strings"
"time"
_ "time/tzdata"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/repos/teams_repo"
"github.com/jackc/pgx/v5"
)
func (s *GameService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) {
return s.teamsRepo.GetParticipation(ctx, token)
}
func (s *GameService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) {
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
}
// Stable, purpose-separated tokens keep links valid when an ambiguous SMTP failure is retried.
// Only the hash is stored in the database; the signing secret must stay stable across workers/restarts.
func participationToken(secret string, id int) string {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "team-participation:v1:%d", id)
return hex.EncodeToString(mac.Sum(nil))
}
func participationEmail(p *teams_repo.Participation, baseURL, token string, location *time.Location) email_sender.Message {
link := strings.TrimRight(baseURL, "/") + "/participation#token=" + token
date := p.StartAt.In(location).Format("02.01.2006 в 15:04 (MST, UTC-07:00)")
body := fmt.Sprintf("Команда «%s», подтвердите участие в игре «%s».\nДата: %s\nУчастников: %d\n\nПодтвердить участие: %s&answer=confirmed\nОтменить участие: %s&answer=cancelled\n\nОтвет можно дать до начала игры. Решение окончательное: изменить его нельзя. Без ответа запись сохраняется со статусом «Ожидает ответа».\nНе пересылайте письмо: ссылка позволяет принять решение за команду.", p.TeamName, p.GameName, date, p.ParticipantsCount, link, link)
markup := "<!doctype html><html lang=\"ru\"><meta charset=\"utf-8\"><body><h2>Подтвердите участие в игре</h2><p>" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»</p><p>" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d</p>", p.ParticipantsCount)
for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} {
markup += `<p><a href="` + html.EscapeString(link+"&answer="+action.value) + `" style="display:inline-block;padding:12px 20px;background:#342a22;color:#fff;border-radius:5px">` + action.label + `</a></p>`
}
markup += "<p>Ответ можно дать до начала игры. <strong>Решение окончательное: изменить его нельзя.</strong></p><p>Без ответа запись сохраняется со статусом «Ожидает ответа».</p><p>Не пересылайте письмо: ссылка позволяет принять решение за команду.</p></body></html>"
return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup}
}
func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) {
base, err := url.Parse(s.appBaseURL)
if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" || secret == "" {
log.Print("participation email worker disabled: configure APP_BASE_URL and PARTICIPATION_SECRET")
return
}
if timezone == "" {
timezone = "Asia/Barnaul"
}
location, err := time.LoadLocation(timezone)
if err != nil {
log.Printf("participation email worker disabled: invalid GAME_TIMEZONE: %v", err)
return
}
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
if err := s.sendParticipationEmails(ctx, sender, secret, location); err != nil && ctx.Err() == nil {
log.Printf("participation email worker: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *GameService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error {
for ctx.Err() == nil {
jobCtx, cancel := context.WithTimeout(ctx, time.Minute)
p, err := s.teamsRepo.ClaimParticipationEmail(jobCtx)
if errors.Is(err, pgx.ErrNoRows) {
cancel()
return nil
}
if err != nil {
cancel()
return err
}
token := participationToken(secret, p.TeamID)
err = s.teamsRepo.SetParticipationToken(jobCtx, p.TeamID, token)
if err == nil {
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
}
cancel()
// Persist even when sending consumed the job's deadline.
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
saveCancel()
if saveErr != nil {
return saveErr
}
}
return ctx.Err()
}
@@ -0,0 +1,241 @@
package game_service
import (
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/repos/teams_repo"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type participationSender struct {
messages []email_sender.Message
err error
}
func (s *participationSender) Send(_ context.Context, m email_sender.Message) error {
s.messages = append(s.messages, m)
return s.err
}
func TestParticipationEmail(t *testing.T) {
token := participationToken("test-secret", 42)
if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) {
t.Fatal("token isolation or retry stability broken")
}
p := &teams_repo.Participation{TeamName: "<script>team</script>", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5}
m := participationEmail(p, "https://example.org", token, time.FixedZone("+07", 7*3600))
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "&lt;script&gt;") || !strings.Contains(m.Body, "19:00") || !strings.Contains(m.Body, "#token="+token) || !strings.Contains(m.HTML, "answer=cancelled") {
t.Fatalf("invalid email: %+v", m)
}
}
func TestParticipationPostgres(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("participation_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
config.ConnConfig.RuntimeParams["timezone"] = "UTC"
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
exec := func(sql string, args ...any) {
t.Helper()
if _, err := pool.Exec(ctx, sql, args...); err != nil {
t.Fatal(err)
}
}
exec(`CREATE TABLE games(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Game',start_at TIMESTAMP NOT NULL,status TEXT DEFAULT 'draft');
CREATE TABLE teams(id SERIAL PRIMARY KEY,name TEXT DEFAULT 'Team',creator_id INTEGER,game_id INTEGER REFERENCES games(id),password TEXT DEFAULT 'secret',status TEXT DEFAULT 'register',created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'));
INSERT INTO games(start_at) VALUES((NOW() AT TIME ZONE 'UTC')+INTERVAL '12 hours'),((NOW() AT TIME ZONE 'UTC')+INTERVAL '2 days');
INSERT INTO teams(game_id,created_at) VALUES(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '2 days'),(1,(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 hour');`)
for _, name := range []string{"20260913130000_add_team_registration.sql", "20260915120000_add_team_participation.sql"} {
data, err := os.ReadFile("../../../migrations/" + name)
if err != nil {
t.Fatal(err)
}
exec(strings.Split(string(data), "-- +goose Down")[0])
}
repo := teams_repo.NewTeamsRepo(pool)
service := &GameService{teamsRepo: repo, appBaseURL: "https://example.org"}
state := func(id int, want string) {
t.Helper()
team, err := repo.GetTeamByID(ctx, id)
if err != nil || team.ParticipationStatus != want {
t.Fatalf("team %d want %s got %+v, %v", id, want, team, err)
}
}
state(1, "pending")
state(2, "pending") // Existing teams are not changed by the migration.
exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org' FROM games WHERE id=1;
INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org');
INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`)
state(3, "pending")
state(4, "confirmed")
state(5, "pending")
// Independent workers must claim each due team just once.
var wg sync.WaitGroup
ids := make(chan int, 8)
errs := make(chan error, 8)
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
p, err := repo.ClaimParticipationEmail(ctx)
if err == nil {
ids <- p.TeamID
} else if !errors.Is(err, pgx.ErrNoRows) {
errs <- err
}
}()
}
wg.Wait()
close(ids)
close(errs)
seen := map[int]bool{}
for id := range ids {
if seen[id] {
t.Fatalf("duplicate claim %d", id)
}
seen[id] = true
}
for err := range errs {
t.Error(err)
}
if len(seen) != 2 || !seen[1] || !seen[3] {
t.Fatalf("wrong eligible teams: %v", seen)
}
exec(`UPDATE teams SET participation_next_attempt_at=NULL`)
sender := &participationSender{err: errors.New("SMTP unavailable")}
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 2 {
t.Fatalf("wanted two attempts: %d", len(sender.messages))
}
team, err := repo.GetTeamByID(ctx, 1)
if err != nil || team.ParticipationEmailError == "" || team.ParticipationEmailSent {
t.Fatalf("failed send not recorded: %+v %v", team, err)
}
sender.err = nil
// Expired lease is recovered after a worker restart.
exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id=3`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("expired lease not recovered")
}
exec(`UPDATE teams SET participation_attempts=8, participation_next_attempt_at=NULL, participation_sent_at=NULL WHERE id=1`)
if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil {
t.Fatal(err)
}
if len(sender.messages) != 4 {
t.Fatal("email retry exceeded the eight-attempt limit")
}
token := participationToken("secret", 1)
if _, err := repo.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("invalid token: %v", err)
}
if _, err := repo.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) {
t.Fatalf("unknown token: %v", err)
}
if _, err := repo.GetParticipation(ctx, token); err != nil {
t.Fatal(err)
}
state(1, "pending") // Viewing a link never answers.
answers := make(chan error, 2)
for _, answer := range []string{"confirmed", "cancelled"} {
go func() { _, err := repo.AnswerParticipation(ctx, token, answer); answers <- err }()
}
success, conflict := 0, 0
for range 2 {
err := <-answers
if err == nil {
success++
} else if errors.Is(err, teams_repo.ErrParticipationFinal) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("concurrent answers: success=%d conflict=%d", success, conflict)
}
p, err := repo.GetParticipation(ctx, token)
if err != nil {
t.Fatal(err)
}
if _, err := repo.AnswerParticipation(ctx, token, p.Status); err != nil {
t.Fatalf("same answer not idempotent: %v", err)
}
// Cancellation excludes teams from shared active listings and password-based gameplay.
token3 := participationToken("secret", 3)
if _, err := repo.AnswerParticipation(ctx, token3, "cancelled"); err != nil {
t.Fatal(err)
}
active, err := repo.GetActiveTeamsByGameID(ctx, 1)
if err != nil {
t.Fatal(err)
}
for _, team := range active {
if team.ID == 3 {
t.Fatal("cancelled team in active list")
}
}
if _, _, _, err := service.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got story: %v", err)
}
if err := service.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team added action: %v", err)
}
if _, err := service.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) {
t.Fatalf("cancelled team got answers: %v", err)
}
token6 := participationToken("secret", 6)
if err := repo.SetParticipationToken(ctx, 6, token6); err != nil {
t.Fatal(err)
}
exec(`UPDATE games SET status='run' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("running game accepted answer: %v", err)
}
exec(`UPDATE games SET status='draft',start_at=(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 second' WHERE id=1`)
if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) {
t.Fatalf("past scheduled start accepted answer: %v", err)
}
state(6, "pending")
}
@@ -8,6 +8,7 @@ import (
"net/mail"
"strconv"
"strings"
"time"
"unicode/utf8"
)
@@ -72,5 +73,10 @@ func (s *GameService) RegisterTeam(ctx context.Context, gameID int, deliveryID s
if err != nil {
return 0, err
}
return s.teamsRepo.RegisterTeam(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password)
game, err := s.gamesRepo.GetGameByID(ctx, gameID)
if err != nil {
return 0, err
}
autoConfirm := time.Now().Before(game.StartAt.Add(-24*time.Hour)) == false
return s.teamsRepo.RegisterTeamWithParticipation(ctx, gameID, deliveryID, registration.Name, registration.Email, registration.ParticipantsCount, password, autoConfirm)
}
+10 -6
View File
@@ -222,7 +222,11 @@ func (s *GameService) AddTeam(
if err != nil {
return nil, err
}
id, err := s.teamsRepo.AddTeam(ctx, creatorId, gameId, name, password)
game, err := s.gamesRepo.GetGameByID(ctx, gameId)
if err != nil {
return nil, err
}
id, err := s.teamsRepo.AddTeamWithParticipation(ctx, creatorId, gameId, name, password, !time.Now().Before(game.StartAt.Add(-24*time.Hour)))
if err != nil {
return nil, err
}
@@ -250,7 +254,7 @@ func (s *GameService) GetTeamActions(ctx context.Context, teamId int, password s
return nil, nil, nil, err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return nil, nil, nil, teams_repo.ErrTeamNotFound
}
@@ -287,7 +291,7 @@ func (s *GameService) AddTeamAction(ctx context.Context, teamId int, password st
return err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return teams_repo.ErrTeamNotFound
}
@@ -386,7 +390,7 @@ func (s *GameService) getTeamAndGame(ctx context.Context, teamId int, password s
if err != nil {
return nil, nil, err
}
if team.Password != password {
if team.Password != password || team.ParticipationStatus == "cancelled" {
return nil, nil, teams_repo.ErrTeamNotFound
}
game, err := s.gamesRepo.GetGameByID(ctx, team.GameId)
@@ -494,7 +498,7 @@ func (s *GameService) GetGameAnswers(ctx context.Context, gameId int) (*GameAnsw
if err != nil {
return nil, err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId)
if err != nil {
return nil, err
}
@@ -581,7 +585,7 @@ func (s *GameService) GetGameResults(ctx context.Context, gameId int) (*GameResu
if err != nil {
return nil, err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, gameId)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, gameId)
if err != nil {
return nil, err
}
+12 -8
View File
@@ -3,12 +3,16 @@ package game_service
import "evening_detective_server/internal/modules/storytelling"
type Team struct {
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
ActionsCount int
Applications []*storytelling.Application
ParticipationStatus string
ParticipationSource string
ParticipationEmailError string
ParticipationEmailSent bool
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
ActionsCount int
Applications []*storytelling.Application
}
+10 -6
View File
@@ -6,12 +6,16 @@ func mapTeam(
o *repos.Team,
) *Team {
return &Team{
ID: o.ID,
CaptainEmail: o.CaptainEmail,
ParticipantsCount: o.ParticipantsCount,
Name: o.Name,
Status: o.Status,
Password: o.Password,
ParticipationStatus: o.ParticipationStatus,
ParticipationSource: o.ParticipationSource,
ParticipationEmailError: o.ParticipationEmailError,
ParticipationEmailSent: o.ParticipationEmailSent,
ID: o.ID,
CaptainEmail: o.CaptainEmail,
ParticipantsCount: o.ParticipantsCount,
Name: o.Name,
Status: o.Status,
Password: o.Password,
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ func (s *GameService) DownloadTeamQR(ctx context.Context, id int) ([]byte, strin
if err != nil {
return nil, "", err
}
teams, err := s.teamsRepo.GetTeamsByGameID(ctx, id)
teams, err := s.teamsRepo.GetActiveTeamsByGameID(ctx, id)
if err != nil {
return nil, "", err
}