Add Discord event announcement functionality to backend
This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
discordadapter "mixmaker/backend/internal/adapter/discord"
|
||||
"mixmaker/backend/internal/adapter/httpapi"
|
||||
"mixmaker/backend/internal/adapter/postgres"
|
||||
"mixmaker/backend/internal/application"
|
||||
@@ -37,7 +38,22 @@ func main() {
|
||||
}
|
||||
defer store.Close()
|
||||
hub := realtime.New()
|
||||
service := application.New(store, hub)
|
||||
var eventAnnouncer application.EventAnnouncer
|
||||
discordBotToken := os.Getenv("DISCORD_BOT_TOKEN")
|
||||
discordAnnouncementChannelID := os.Getenv("DISCORD_ANNOUNCEMENT_CHANNEL_ID")
|
||||
if discordBotToken != "" || discordAnnouncementChannelID != "" {
|
||||
eventAnnouncer, err = discordadapter.New(discordadapter.Config{
|
||||
BotToken: discordBotToken,
|
||||
ChannelID: discordAnnouncementChannelID,
|
||||
PublicURL: env("PUBLIC_URL", env("FRONTEND_URL", "")),
|
||||
Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("Discord announcer configuration failed", "error", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
service := application.New(store, hub, eventAnnouncer)
|
||||
cfg := httpapi.Config{
|
||||
DiscordClientID: required("DISCORD_CLIENT_ID"),
|
||||
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),
|
||||
|
||||
203
backend/internal/adapter/discord/announcer.go
Normal file
203
backend/internal/adapter/discord/announcer.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
const defaultAPIBaseURL = "https://discord.com/api/v10"
|
||||
|
||||
type Config struct {
|
||||
BotToken string
|
||||
ChannelID string
|
||||
PublicURL string
|
||||
Locale string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Announcer struct {
|
||||
botToken string
|
||||
channelID string
|
||||
publicURL string
|
||||
locale string
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type messagePayload struct {
|
||||
Content string `json:"content"`
|
||||
Embeds []embed `json:"embeds"`
|
||||
Components []actionRow `json:"components"`
|
||||
AllowedMentions allowedMentions `json:"allowed_mentions"`
|
||||
}
|
||||
|
||||
type embed struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Color int `json:"color"`
|
||||
Fields []embedField `json:"fields"`
|
||||
}
|
||||
|
||||
type embedField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Inline bool `json:"inline"`
|
||||
}
|
||||
|
||||
type actionRow struct {
|
||||
Type int `json:"type"`
|
||||
Components []button `json:"components"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Type int `json:"type"`
|
||||
Style int `json:"style"`
|
||||
Label string `json:"label"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type allowedMentions struct {
|
||||
Parse []string `json:"parse"`
|
||||
}
|
||||
|
||||
type translations struct {
|
||||
titlePrefix string
|
||||
startsAt string
|
||||
deadline string
|
||||
registerButton string
|
||||
}
|
||||
|
||||
func New(config Config) (*Announcer, error) {
|
||||
if strings.TrimSpace(config.BotToken) == "" {
|
||||
return nil, errors.New("discord bot token is required")
|
||||
}
|
||||
if strings.TrimSpace(config.ChannelID) == "" {
|
||||
return nil, errors.New("discord announcement channel ID is required")
|
||||
}
|
||||
publicURL, err := url.Parse(strings.TrimSpace(config.PublicURL))
|
||||
if err != nil || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || publicURL.Host == "" {
|
||||
return nil, errors.New("discord announcement public URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
locale := strings.ToLower(strings.TrimSpace(config.Locale))
|
||||
if locale == "" {
|
||||
locale = "ru"
|
||||
}
|
||||
if locale != "ru" && locale != "en" {
|
||||
return nil, fmt.Errorf("unsupported discord announcement locale %q", locale)
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
httpClient := config.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &Announcer{
|
||||
botToken: config.BotToken,
|
||||
channelID: config.ChannelID,
|
||||
publicURL: strings.TrimRight(strings.TrimSpace(config.PublicURL), "/"),
|
||||
locale: locale,
|
||||
apiBaseURL: apiBaseURL,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
err := a.announceEventCreated(ctx, event)
|
||||
if err != nil {
|
||||
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
payload, err := json.Marshal(a.createdMessage(event))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Discord message: %w", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/channels/%s/messages", a.apiBaseURL, url.PathEscape(a.channelID))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create Discord request: %w", err)
|
||||
}
|
||||
request.Header.Set("Authorization", "Bot "+a.botToken)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := a.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send Discord message: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
return fmt.Errorf("Discord returned %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
|
||||
text := announcementTranslations(a.locale)
|
||||
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
|
||||
return messagePayload{
|
||||
Content: "@everyone",
|
||||
Embeds: []embed{{
|
||||
Title: truncate(text.titlePrefix+event.Name, 256),
|
||||
Description: truncate(event.Description, 4096),
|
||||
URL: eventURL,
|
||||
Color: 0xe9358f,
|
||||
Fields: []embedField{
|
||||
{Name: text.startsAt, Value: fmt.Sprintf("<t:%d:F> · <t:%d:R>", event.StartsAt.Unix(), event.StartsAt.Unix()), Inline: true},
|
||||
{Name: text.deadline, Value: fmt.Sprintf("<t:%d:F>", event.RegistrationDeadline.Unix()), Inline: true},
|
||||
},
|
||||
}},
|
||||
Components: []actionRow{{
|
||||
Type: 1,
|
||||
Components: []button{{
|
||||
Type: 2,
|
||||
Style: 5,
|
||||
Label: text.registerButton,
|
||||
URL: eventURL,
|
||||
}},
|
||||
}},
|
||||
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
|
||||
}
|
||||
}
|
||||
|
||||
func announcementTranslations(locale string) translations {
|
||||
if locale == "en" {
|
||||
return translations{
|
||||
titlePrefix: "New mix: ",
|
||||
startsAt: "Starts",
|
||||
deadline: "Registration deadline",
|
||||
registerButton: "Register",
|
||||
}
|
||||
}
|
||||
return translations{
|
||||
titlePrefix: "Новый микс: ",
|
||||
startsAt: "Начало",
|
||||
deadline: "Регистрация до",
|
||||
registerButton: "Зарегистрироваться",
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(value string, maxRunes int) string {
|
||||
if utf8.RuneCountInString(value) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:maxRunes-1]) + "…"
|
||||
}
|
||||
97
backend/internal/adapter/discord/announcer_test.go
Normal file
97
backend/internal/adapter/discord/announcer_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestAnnounceEventCreated(t *testing.T) {
|
||||
var received messagePayload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/channels/channel-123/messages" {
|
||||
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
if authorization := request.Header.Get("Authorization"); authorization != "Bot secret-token" {
|
||||
t.Errorf("unexpected Authorization header: %q", authorization)
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
|
||||
t.Errorf("decode payload: %v", err)
|
||||
}
|
||||
response.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
announcer, err := New(Config{
|
||||
BotToken: "secret-token",
|
||||
ChannelID: "channel-123",
|
||||
PublicURL: "https://mix.example.com",
|
||||
Locale: "ru",
|
||||
APIBaseURL: server.URL,
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Date(2026, time.July, 20, 18, 0, 0, 0, time.UTC)
|
||||
event := domain.Event{
|
||||
ID: "event-456",
|
||||
Name: "Воскресный микс",
|
||||
Description: "Собираемся на Bo3",
|
||||
StartsAt: start,
|
||||
RegistrationDeadline: start.Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if received.Content != "@everyone" {
|
||||
t.Fatalf("unexpected content: %q", received.Content)
|
||||
}
|
||||
if len(received.AllowedMentions.Parse) != 1 || received.AllowedMentions.Parse[0] != "everyone" {
|
||||
t.Fatalf("unexpected allowed mentions: %+v", received.AllowedMentions)
|
||||
}
|
||||
if len(received.Embeds) != 1 {
|
||||
t.Fatalf("expected one embed, got %d", len(received.Embeds))
|
||||
}
|
||||
eventURL := "https://mix.example.com/events/event-456"
|
||||
if received.Embeds[0].Title != "Новый микс: Воскресный микс" ||
|
||||
received.Embeds[0].Description != event.Description ||
|
||||
received.Embeds[0].URL != eventURL ||
|
||||
len(received.Embeds[0].Fields) != 2 {
|
||||
t.Fatalf("unexpected embed: %+v", received.Embeds[0])
|
||||
}
|
||||
if len(received.Components) != 1 || len(received.Components[0].Components) != 1 {
|
||||
t.Fatalf("unexpected components: %+v", received.Components)
|
||||
}
|
||||
linkButton := received.Components[0].Components[0]
|
||||
if linkButton.Style != 5 || linkButton.Label != "Зарегистрироваться" || linkButton.URL != eventURL {
|
||||
t.Fatalf("unexpected registration button: %+v", linkButton)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
|
||||
}))
|
||||
defer server.Close()
|
||||
announcer, err := New(Config{
|
||||
BotToken: "secret-token",
|
||||
ChannelID: "channel-123",
|
||||
PublicURL: "https://mix.example.com",
|
||||
APIBaseURL: server.URL,
|
||||
HTTPClient: server.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
|
||||
t.Fatal("expected Discord error")
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,11 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
||||
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
|
||||
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
|
||||
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
|
||||
api.Get("/api/events/{eventID}/bracket-draft", s.getBracketDraft)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/initialize", s.initializeBracket)
|
||||
api.Put("/api/events/{eventID}/bracket-draft", s.updateBracket)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/reset", s.resetBracket)
|
||||
api.Post("/api/events/{eventID}/bracket-draft/confirm", s.confirmBracket)
|
||||
api.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage)
|
||||
api.Post("/api/events/{eventID}/start", s.startScrim)
|
||||
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||
|
||||
@@ -168,6 +168,56 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) getBracketDraft(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetBracketDraft(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) initializeBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.InitializeBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) updateBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Matches []domain.BracketMatch `json:"matches"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.UpdateBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.Matches, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) resetBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.ResetBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) confirmBracket(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.ConfirmBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
|
||||
@@ -371,6 +371,40 @@ func (s *Store) ResetRoster(ctx context.Context, eventID string) error {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) SaveBracketDraft(ctx context.Context, draft domain.BracketDraft, expectedVersion int) (domain.BracketDraft, error) {
|
||||
body, _ := json.Marshal(draft)
|
||||
if expectedVersion < 0 {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO event_bracket_drafts(event_id,body,version,confirmed) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,confirmed=excluded.confirmed,updated_at=now()`,
|
||||
draft.EventID, body, draft.Version, draft.Confirmed)
|
||||
return draft, err
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE event_bracket_drafts SET body=$2,version=$3,confirmed=$4,updated_at=now()
|
||||
WHERE event_id=$1 AND version=$5`, draft.EventID, body, draft.Version, draft.Confirmed, expectedVersion)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return draft, err
|
||||
}
|
||||
|
||||
func (s *Store) GetBracketDraft(ctx context.Context, eventID string) (domain.BracketDraft, error) {
|
||||
var body []byte
|
||||
var version int
|
||||
var confirmed bool
|
||||
var draft domain.BracketDraft
|
||||
err := s.pool.QueryRow(ctx, `SELECT body,version,confirmed FROM event_bracket_drafts WHERE event_id=$1`, eventID).Scan(&body, &version, &confirmed)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &draft)
|
||||
draft.Version, draft.Confirmed = version, confirmed
|
||||
}
|
||||
return draft, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteBracketDraft(ctx context.Context, eventID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM event_bracket_drafts WHERE event_id=$1`, eventID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVersion int, series []domain.Series, tournament *domain.Tournament) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -386,8 +420,8 @@ func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVers
|
||||
}
|
||||
if tournament != nil {
|
||||
body, _ := json.Marshal(tournament)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)`,
|
||||
tournament.ID, tournament.EventID, body); err != nil {
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)`,
|
||||
tournament.ID, tournament.EventID, body, tournament.Version); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
}
|
||||
@@ -600,8 +634,12 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
|
||||
|
||||
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||
body, _ := json.Marshal(t)
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body)
|
||||
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
|
||||
WHERE excluded.version=0 OR tournaments.version=excluded.version-1`, t.ID, t.EventID, body, t.Version)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
@@ -652,6 +690,24 @@ func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tourna
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tournament.Matches) > 0 {
|
||||
for index := range tournament.Matches {
|
||||
match := &tournament.Matches[index]
|
||||
if series, ok := live[match.SeriesID]; ok {
|
||||
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
|
||||
match.Series = &series
|
||||
} else if series, ok := live[match.ID]; ok {
|
||||
match.SeriesID = series.ID
|
||||
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
|
||||
match.Series = &series
|
||||
}
|
||||
}
|
||||
if err := tournament.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
return nil
|
||||
}
|
||||
for round := range tournament.Rounds {
|
||||
for match := range tournament.Rounds[round] {
|
||||
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
|
||||
|
||||
@@ -47,6 +47,13 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
||||
if !rosterTable {
|
||||
t.Fatal("event_rosters table is missing")
|
||||
}
|
||||
var bracketTable bool
|
||||
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.event_bracket_drafts') IS NOT NULL`).Scan(&bracketTable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bracketTable {
|
||||
t.Fatal("event_bracket_drafts table is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullScrimPipeline(t *testing.T) {
|
||||
@@ -84,7 +91,7 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
|
||||
service := application.New(store, discardPublisher{})
|
||||
service := application.New(store, discardPublisher{}, nil)
|
||||
admin := domain.Account{ID: accountID, Role: domain.RoleAdmin}
|
||||
now := time.Now().UTC()
|
||||
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
||||
@@ -130,6 +137,18 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft, err := service.InitializeBracket(ctx, admin, eventID, event.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft, err = service.ConfirmBracket(ctx, admin, eventID, draft.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err = store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started, err := service.StartScrim(ctx, admin, eventID, event.Version)
|
||||
if err != nil || started.Series == nil {
|
||||
t.Fatalf("start failed: %#v, %v", started, err)
|
||||
@@ -200,7 +219,7 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
service := application.New(store, discardPublisher{})
|
||||
service := application.New(store, discardPublisher{}, nil)
|
||||
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
|
||||
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
|
||||
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
|
||||
@@ -213,15 +232,15 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tournament, err := domain.NewTournament(tournamentID, eventID, event.Name, []string{"team-a", "team-b", "team-c", "team-d"})
|
||||
draft, err := domain.NewBracketDraft(eventID, []string{"team-a", "team-b", "team-c", "team-d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
|
||||
tournament, err := domain.NewGraphTournament(tournamentID, eventID, event.Name, *draft)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for match := range tournament.Rounds[0] {
|
||||
slot := tournament.Rounds[0][match]
|
||||
for match, slot := range tournament.ReadyMatches() {
|
||||
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
@@ -234,6 +253,16 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
for index := range tournament.Matches {
|
||||
if tournament.Matches[index].ID == slot.ID {
|
||||
tournament.Matches[index].SeriesID = series.ID
|
||||
tournament.Matches[index].Series = series
|
||||
}
|
||||
}
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hydrated, err := store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
@@ -242,17 +271,23 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
if hydrated.Rounds[0][0].Phase != domain.MapBanPhase || hydrated.Rounds[0][1].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("parallel series were not hydrated independently: %s / %s", hydrated.Rounds[0][0].Phase, hydrated.Rounds[0][1].Phase)
|
||||
}
|
||||
for match := range hydrated.Rounds[0] {
|
||||
hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID
|
||||
hydrated.Rounds[0][match].Phase = domain.SeriesComplete
|
||||
if err = hydrated.Advance(0, match); err != nil {
|
||||
for index := range hydrated.Matches {
|
||||
if hydrated.Matches[index].Round != 0 || hydrated.Matches[index].Series == nil {
|
||||
continue
|
||||
}
|
||||
completed := *hydrated.Matches[index].Series
|
||||
completed.WinnerTeamID, completed.Phase, completed.Version = completed.TeamAID, domain.SeriesComplete, completed.Version+1
|
||||
if _, err = store.SaveSeries(ctx, completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = hydrated.ApplySeries(completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finalSlot := hydrated.Rounds[1][0]
|
||||
finalSlot := hydrated.ReadyMatches()[0]
|
||||
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -261,8 +296,8 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("final was not persisted and hydrated: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err)
|
||||
if err != nil || hydrated.Matches[len(hydrated.Matches)-1].Series == nil || hydrated.Matches[len(hydrated.Matches)-1].Series.Phase != domain.CoinTossPending {
|
||||
t.Fatalf("final was not persisted and hydrated: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ type Store interface {
|
||||
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
|
||||
GetRoster(context.Context, string) (domain.RosterDraft, error)
|
||||
ResetRoster(context.Context, string) error
|
||||
SaveBracketDraft(context.Context, domain.BracketDraft, int) (domain.BracketDraft, error)
|
||||
GetBracketDraft(context.Context, string) (domain.BracketDraft, error)
|
||||
DeleteBracketDraft(context.Context, string) error
|
||||
StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
|
||||
DeleteEvent(context.Context, string) error
|
||||
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
|
||||
@@ -55,14 +58,26 @@ type Store interface {
|
||||
|
||||
type Publisher interface{ Publish(topic string, value any) }
|
||||
|
||||
type Service struct {
|
||||
Store Store
|
||||
Bus Publisher
|
||||
Now func() time.Time
|
||||
type EventAnnouncer interface {
|
||||
AnnounceEventCreated(context.Context, domain.Event) error
|
||||
}
|
||||
|
||||
func New(store Store, bus Publisher) *Service {
|
||||
return &Service{Store: store, Bus: bus, Now: func() time.Time { return time.Now().UTC() }}
|
||||
type Service struct {
|
||||
Store Store
|
||||
Bus Publisher
|
||||
EventAnnouncer EventAnnouncer
|
||||
AnnouncementTimeout time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(store Store, bus Publisher, eventAnnouncer EventAnnouncer) *Service {
|
||||
return &Service{
|
||||
Store: store,
|
||||
Bus: bus,
|
||||
EventAnnouncer: eventAnnouncer,
|
||||
AnnouncementTimeout: 3 * time.Second,
|
||||
Now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
@@ -160,6 +175,11 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
|
||||
out, err := s.Store.CreateEvent(ctx, event)
|
||||
if err == nil {
|
||||
s.Bus.Publish("events", out)
|
||||
if s.EventAnnouncer != nil {
|
||||
announcementCtx, cancel := context.WithTimeout(ctx, s.AnnouncementTimeout)
|
||||
_ = s.EventAnnouncer.AnnounceEventCreated(announcementCtx, out)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
112
backend/internal/application/service_test.go
Normal file
112
backend/internal/application/service_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type createEventStore struct {
|
||||
Store
|
||||
created domain.Event
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *createEventStore) CreateEvent(_ context.Context, event domain.Event) (domain.Event, error) {
|
||||
s.created = event
|
||||
if s.err != nil {
|
||||
return domain.Event{}, s.err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
type recordingPublisher struct {
|
||||
topics []string
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) Publish(topic string, _ any) {
|
||||
p.topics = append(p.topics, topic)
|
||||
}
|
||||
|
||||
type recordingAnnouncer struct {
|
||||
events []domain.Event
|
||||
err error
|
||||
hasDeadline bool
|
||||
}
|
||||
|
||||
func (a *recordingAnnouncer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
a.events = append(a.events, event)
|
||||
_, a.hasDeadline = ctx.Deadline()
|
||||
return a.err
|
||||
}
|
||||
|
||||
func TestCreateEventAnnouncesAfterPersistence(t *testing.T) {
|
||||
store := &createEventStore{}
|
||||
bus := &recordingPublisher{}
|
||||
announcer := &recordingAnnouncer{}
|
||||
service := New(store, bus, announcer)
|
||||
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
|
||||
service.Now = func() time.Time { return now }
|
||||
|
||||
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(announcer.events) != 1 {
|
||||
t.Fatalf("expected one announcement, got %d", len(announcer.events))
|
||||
}
|
||||
if announcer.events[0].ID != created.ID || created.ID == "" {
|
||||
t.Fatalf("announced event ID %q does not match created event ID %q", announcer.events[0].ID, created.ID)
|
||||
}
|
||||
if !announcer.hasDeadline {
|
||||
t.Fatal("announcement context must have a timeout")
|
||||
}
|
||||
if len(bus.topics) != 1 || bus.topics[0] != "events" {
|
||||
t.Fatalf("unexpected published topics: %v", bus.topics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventDoesNotAnnouncePersistenceFailure(t *testing.T) {
|
||||
persistenceErr := errors.New("database unavailable")
|
||||
store := &createEventStore{err: persistenceErr}
|
||||
announcer := &recordingAnnouncer{}
|
||||
service := New(store, &recordingPublisher{}, announcer)
|
||||
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
|
||||
service.Now = func() time.Time { return now }
|
||||
|
||||
_, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
|
||||
if !errors.Is(err, persistenceErr) {
|
||||
t.Fatalf("expected persistence error, got %v", err)
|
||||
}
|
||||
if len(announcer.events) != 0 {
|
||||
t.Fatalf("expected no announcements, got %d", len(announcer.events))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventIgnoresAnnouncementFailure(t *testing.T) {
|
||||
announcementErr := errors.New("discord unavailable")
|
||||
announcer := &recordingAnnouncer{err: announcementErr}
|
||||
service := New(&createEventStore{}, &recordingPublisher{}, announcer)
|
||||
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
|
||||
service.Now = func() time.Time { return now }
|
||||
|
||||
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
|
||||
if err != nil {
|
||||
t.Fatalf("announcement failure must not fail event creation: %v", err)
|
||||
}
|
||||
if created.ID == "" || len(announcer.events) != 1 {
|
||||
t.Fatalf("event was not created and announced exactly once: event=%+v announcements=%d", created, len(announcer.events))
|
||||
}
|
||||
}
|
||||
|
||||
func validEvent(now time.Time) domain.Event {
|
||||
return domain.Event{
|
||||
Name: "Sunday Mix",
|
||||
Description: "Community scrim",
|
||||
StartsAt: now.Add(24 * time.Hour),
|
||||
EndsAt: now.Add(28 * time.Hour),
|
||||
}
|
||||
}
|
||||
@@ -393,6 +393,132 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even
|
||||
return event, err
|
||||
}
|
||||
|
||||
func (s *Service) InitializeBracket(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion int) (domain.BracketDraft, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.BracketDraft{}, domain.ErrForbidden
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
roster, err := s.Store.GetRoster(ctx, eventID)
|
||||
if err != nil {
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
if !roster.Confirmed {
|
||||
return domain.BracketDraft{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
|
||||
}
|
||||
teamIDs := make([]string, len(roster.Teams))
|
||||
for index := range roster.Teams {
|
||||
teamIDs[index] = roster.Teams[index].ID
|
||||
}
|
||||
draft, err := domain.NewBracketDraft(eventID, teamIDs)
|
||||
if err != nil {
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
old := event.Version
|
||||
if err = event.Transition([]domain.EventState{domain.RostersConfirmed}, domain.BracketDraftState, expectedEventVersion); err != nil {
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
if _, err = s.Store.SaveBracketDraft(ctx, *draft, -1); err != nil {
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
event.UpdatedAt = s.Now()
|
||||
if _, err = s.Store.SaveEventWorkflow(ctx, event, old); err == nil {
|
||||
s.Bus.Publish("event:"+eventID, event)
|
||||
s.Bus.Publish("bracket:"+eventID, draft)
|
||||
}
|
||||
return *draft, err
|
||||
}
|
||||
|
||||
func (s *Service) ResetBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.BracketDraft{}, domain.ErrForbidden
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil || event.State != domain.BracketDraftState {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
|
||||
}
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
current, err := s.Store.GetBracketDraft(ctx, eventID)
|
||||
if err != nil || current.Version != expectedVersion {
|
||||
if err == nil {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return current, err
|
||||
}
|
||||
draft, err := domain.NewBracketDraft(eventID, current.TeamIDs)
|
||||
if err != nil {
|
||||
return current, err
|
||||
}
|
||||
draft.Version = current.Version + 1
|
||||
out, err := s.Store.SaveBracketDraft(ctx, *draft, current.Version)
|
||||
if err == nil {
|
||||
s.Bus.Publish("bracket:"+eventID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, eventID string, matches []domain.BracketMatch, expectedVersion int) (domain.BracketDraft, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.BracketDraft{}, domain.ErrForbidden
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil || event.State != domain.BracketDraftState {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
|
||||
}
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
draft, err := s.Store.GetBracketDraft(ctx, eventID)
|
||||
if err != nil || draft.Version != expectedVersion {
|
||||
if err == nil {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return draft, err
|
||||
}
|
||||
draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1
|
||||
if err = draft.Validate(); err != nil {
|
||||
return draft, err
|
||||
}
|
||||
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
|
||||
if err == nil {
|
||||
s.Bus.Publish("bracket:"+eventID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) ConfirmBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.BracketDraft{}, domain.ErrForbidden
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil || event.State != domain.BracketDraftState {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
|
||||
}
|
||||
return domain.BracketDraft{}, err
|
||||
}
|
||||
draft, err := s.Store.GetBracketDraft(ctx, eventID)
|
||||
if err != nil || draft.Version != expectedVersion {
|
||||
if err == nil {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
return draft, err
|
||||
}
|
||||
if err = draft.Validate(); err != nil {
|
||||
return draft, err
|
||||
}
|
||||
draft.Confirmed, draft.Version = true, draft.Version+1
|
||||
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
|
||||
if err == nil {
|
||||
s.Bus.Publish("bracket:"+eventID, out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
|
||||
if !actor.IsStaff() {
|
||||
return domain.Event{}, domain.ErrForbidden
|
||||
@@ -412,6 +538,8 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
|
||||
previous = domain.Balancing
|
||||
case domain.RostersConfirmed:
|
||||
previous = domain.RostersDraft
|
||||
case domain.BracketDraftState:
|
||||
previous = domain.RostersConfirmed
|
||||
default:
|
||||
return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict)
|
||||
}
|
||||
@@ -435,6 +563,9 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
|
||||
return event, rosterErr
|
||||
}
|
||||
}
|
||||
if event.State == domain.RostersConfirmed && previous == domain.RostersConfirmed {
|
||||
_ = s.Store.DeleteBracketDraft(ctx, eventID)
|
||||
}
|
||||
event.UpdatedAt = s.Now()
|
||||
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
|
||||
if err == nil {
|
||||
@@ -459,52 +590,50 @@ func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID
|
||||
if !roster.Confirmed {
|
||||
return ScrimStart{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
|
||||
}
|
||||
draft, err := s.Store.GetBracketDraft(ctx, eventID)
|
||||
if err != nil {
|
||||
return ScrimStart{}, err
|
||||
}
|
||||
if !draft.Confirmed {
|
||||
return ScrimStart{}, fmt.Errorf("%w: bracket is not confirmed", domain.ErrConflict)
|
||||
}
|
||||
if err = draft.Validate(); err != nil {
|
||||
return ScrimStart{}, err
|
||||
}
|
||||
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
|
||||
if err != nil {
|
||||
return ScrimStart{}, err
|
||||
}
|
||||
oldVersion := event.Version
|
||||
if err = event.Transition([]domain.EventState{domain.RostersConfirmed}, domain.Live, expectedVersion); err != nil {
|
||||
if err = event.Transition([]domain.EventState{domain.BracketDraftState}, domain.Live, expectedVersion); err != nil {
|
||||
return ScrimStart{}, err
|
||||
}
|
||||
result := ScrimStart{}
|
||||
seriesToSave := make([]domain.Series, 0)
|
||||
teamIDs := make([]string, len(roster.Teams))
|
||||
for i := range roster.Teams {
|
||||
teamIDs[i] = roster.Teams[i].ID
|
||||
tournament, err := domain.NewGraphTournament(NewID(), eventID, event.Name, draft)
|
||||
if err != nil {
|
||||
return ScrimStart{}, err
|
||||
}
|
||||
if len(teamIDs) == 2 {
|
||||
series, createErr := domain.NewSeries(NewID(), eventID, "", [2]string{teamIDs[0], teamIDs[1]}, rules)
|
||||
for _, ready := range tournament.ReadyMatches() {
|
||||
series, createErr := domain.NewSeries(ready.ID, eventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
|
||||
if createErr != nil {
|
||||
return ScrimStart{}, createErr
|
||||
}
|
||||
seriesToSave = append(seriesToSave, *series)
|
||||
event.ActiveSeriesID = series.ID
|
||||
result.Series = series
|
||||
} else if len(teamIDs) >= 4 {
|
||||
tournament, createErr := domain.NewTournament(NewID(), eventID, event.Name, teamIDs)
|
||||
if createErr != nil {
|
||||
return ScrimStart{}, createErr
|
||||
}
|
||||
for round := range tournament.Rounds {
|
||||
for match := range tournament.Rounds[round] {
|
||||
base := tournament.Rounds[round][match]
|
||||
if base.TeamAID == "" || base.TeamBID == "" {
|
||||
continue
|
||||
}
|
||||
series, seriesErr := domain.NewSeries(base.ID, eventID, tournament.ID, [2]string{base.TeamAID, base.TeamBID}, rules)
|
||||
if seriesErr != nil {
|
||||
return ScrimStart{}, seriesErr
|
||||
}
|
||||
tournament.Rounds[round][match] = *series
|
||||
seriesToSave = append(seriesToSave, *series)
|
||||
for index := range tournament.Matches {
|
||||
if tournament.Matches[index].ID == ready.ID {
|
||||
tournament.Matches[index].SeriesID = series.ID
|
||||
tournament.Matches[index].Series = series
|
||||
}
|
||||
}
|
||||
event.TournamentID = tournament.ID
|
||||
result.Tournament = tournament
|
||||
} else {
|
||||
return ScrimStart{}, fmt.Errorf("%w: scrim needs two or at least four teams", domain.ErrInvalid)
|
||||
seriesToSave = append(seriesToSave, *series)
|
||||
if result.Series == nil {
|
||||
result.Series = series
|
||||
event.ActiveSeriesID = series.ID
|
||||
}
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
event.TournamentID = tournament.ID
|
||||
result.Tournament = tournament
|
||||
event.UpdatedAt = s.Now()
|
||||
err = s.Store.StartScrim(ctx, event, oldVersion, seriesToSave, result.Tournament)
|
||||
if err == nil {
|
||||
@@ -562,6 +691,9 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if len(tournament.Matches) > 0 {
|
||||
return out, s.advanceGraphTournament(ctx, out, tournament)
|
||||
}
|
||||
for round := range tournament.Rounds {
|
||||
for match := range tournament.Rounds[round] {
|
||||
if tournament.Rounds[round][match].ID != out.ID {
|
||||
@@ -610,6 +742,67 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) advanceGraphTournament(ctx context.Context, completed domain.Series, tournament domain.Tournament) error {
|
||||
event, err := s.Store.GetEvent(ctx, completed.EventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if err = tournament.ApplySeries(completed); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ready := range tournament.ReadyMatches() {
|
||||
series, getErr := s.Store.GetSeries(ctx, ready.ID)
|
||||
if getErr == domain.ErrNotFound {
|
||||
created, createErr := domain.NewSeries(ready.ID, completed.EventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
|
||||
if createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
series = *created
|
||||
if _, createErr = s.Store.SaveSeries(ctx, series); createErr != nil && createErr != domain.ErrConflict {
|
||||
return createErr
|
||||
}
|
||||
} else if getErr != nil {
|
||||
return getErr
|
||||
}
|
||||
for index := range tournament.Matches {
|
||||
if tournament.Matches[index].ID == ready.ID {
|
||||
tournament.Matches[index].SeriesID = series.ID
|
||||
copy := series
|
||||
tournament.Matches[index].Series = ©
|
||||
}
|
||||
}
|
||||
}
|
||||
tournament.BuildRounds()
|
||||
if _, err = s.Store.SaveTournament(ctx, tournament); err != domain.ErrConflict {
|
||||
break
|
||||
}
|
||||
tournament, err = s.Store.GetTournament(ctx, tournament.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Bus.Publish("tournament:"+tournament.ID, tournament)
|
||||
s.Bus.Publish("event:"+completed.EventID, tournament)
|
||||
if tournament.Complete() {
|
||||
event, err = s.Store.GetEvent(ctx, completed.EventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := event.Version
|
||||
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
|
||||
_, err = s.Store.SaveEventWorkflow(ctx, event, old)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, player domain.Player, seriesID, actingTeamID string, expectedVersion int, action string, mutation func(*domain.Series, domain.Ruleset) error) (domain.Series, error) {
|
||||
series, err := s.Store.GetSeries(ctx, seriesID)
|
||||
if err != nil {
|
||||
|
||||
262
backend/internal/domain/bracket.go
Normal file
262
backend/internal/domain/bracket.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type SlotSourceKind string
|
||||
|
||||
const (
|
||||
SlotTeam SlotSourceKind = "Team"
|
||||
SlotWinner SlotSourceKind = "Winner"
|
||||
SlotLoser SlotSourceKind = "Loser"
|
||||
)
|
||||
|
||||
type SlotSource struct {
|
||||
Kind SlotSourceKind `json:"kind"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
MatchID string `json:"matchId,omitempty"`
|
||||
}
|
||||
|
||||
type BracketMatch struct {
|
||||
ID string `json:"id"`
|
||||
Round int `json:"round"`
|
||||
Order int `json:"order"`
|
||||
SlotA SlotSource `json:"slotA"`
|
||||
SlotB SlotSource `json:"slotB"`
|
||||
SeriesID string `json:"seriesId,omitempty"`
|
||||
TeamAID string `json:"teamAId,omitempty"`
|
||||
TeamBID string `json:"teamBId,omitempty"`
|
||||
WinnerTeamID string `json:"winnerTeamId,omitempty"`
|
||||
Series *Series `json:"series,omitempty"`
|
||||
}
|
||||
|
||||
type BracketDraft struct {
|
||||
EventID string `json:"eventId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Matches []BracketMatch `json:"matches"`
|
||||
Version int `json:"version"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) {
|
||||
if eventID == "" || len(teamIDs) < 2 {
|
||||
return nil, fmt.Errorf("%w: bracket needs at least two teams", ErrInvalid)
|
||||
}
|
||||
draft := &BracketDraft{EventID: eventID, TeamIDs: slices.Clone(teamIDs)}
|
||||
add := func(round, order int, a, b SlotSource) string {
|
||||
id := fmt.Sprintf("%s-match-%d", eventID, len(draft.Matches)+1)
|
||||
draft.Matches = append(draft.Matches, BracketMatch{ID: id, Round: round, Order: order, SlotA: a, SlotB: b})
|
||||
return id
|
||||
}
|
||||
team := func(id string) SlotSource { return SlotSource{Kind: SlotTeam, TeamID: id} }
|
||||
winner := func(id string) SlotSource { return SlotSource{Kind: SlotWinner, MatchID: id} }
|
||||
loser := func(id string) SlotSource { return SlotSource{Kind: SlotLoser, MatchID: id} }
|
||||
if len(teamIDs) == 2 {
|
||||
add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
||||
} else if len(teamIDs) == 3 {
|
||||
first := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
||||
second := add(1, 0, loser(first), team(teamIDs[2]))
|
||||
add(2, 0, winner(first), winner(second))
|
||||
} else if len(teamIDs)&(len(teamIDs)-1) == 0 {
|
||||
previous := make([]string, 0, len(teamIDs)/2)
|
||||
for i := 0; i < len(teamIDs); i += 2 {
|
||||
previous = append(previous, add(0, i/2, team(teamIDs[i]), team(teamIDs[i+1])))
|
||||
}
|
||||
for round := 1; len(previous) > 1; round++ {
|
||||
next := make([]string, 0, len(previous)/2)
|
||||
for i := 0; i < len(previous); i += 2 {
|
||||
next = append(next, add(round, i/2, winner(previous[i]), winner(previous[i+1])))
|
||||
}
|
||||
previous = next
|
||||
}
|
||||
} else {
|
||||
previous := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
||||
for i := 2; i < len(teamIDs); i++ {
|
||||
previous = add(i-1, 0, winner(previous), team(teamIDs[i]))
|
||||
}
|
||||
}
|
||||
return draft, draft.Validate()
|
||||
}
|
||||
|
||||
func (d BracketDraft) Validate() error {
|
||||
if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 {
|
||||
return fmt.Errorf("%w: incomplete bracket", ErrInvalid)
|
||||
}
|
||||
teams := make(map[string]bool, len(d.TeamIDs))
|
||||
for _, id := range d.TeamIDs {
|
||||
if id == "" || teams[id] {
|
||||
return fmt.Errorf("%w: invalid bracket teams", ErrInvalid)
|
||||
}
|
||||
teams[id] = true
|
||||
}
|
||||
matches := make(map[string]BracketMatch, len(d.Matches))
|
||||
positions := make(map[string]bool, len(d.Matches))
|
||||
maxRound := -1
|
||||
for _, match := range d.Matches {
|
||||
position := fmt.Sprintf("%d:%d", match.Round, match.Order)
|
||||
if match.ID == "" || match.Round < 0 || match.Order < 0 || matches[match.ID].ID != "" || positions[position] {
|
||||
return fmt.Errorf("%w: invalid bracket match", ErrInvalid)
|
||||
}
|
||||
matches[match.ID] = match
|
||||
positions[position] = true
|
||||
if match.Round > maxRound {
|
||||
maxRound = match.Round
|
||||
}
|
||||
}
|
||||
finals := 0
|
||||
for _, match := range d.Matches {
|
||||
if match.Round == maxRound {
|
||||
finals++
|
||||
}
|
||||
for _, source := range []SlotSource{match.SlotA, match.SlotB} {
|
||||
switch source.Kind {
|
||||
case SlotTeam:
|
||||
if !teams[source.TeamID] || source.MatchID != "" {
|
||||
return fmt.Errorf("%w: unknown team source", ErrInvalid)
|
||||
}
|
||||
case SlotWinner, SlotLoser:
|
||||
upstream, ok := matches[source.MatchID]
|
||||
if !ok || upstream.Round >= match.Round || source.TeamID != "" {
|
||||
return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID {
|
||||
return fmt.Errorf("%w: a team cannot play itself", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if finals != 1 {
|
||||
return fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewGraphTournament(id, eventID, name string, draft BracketDraft) (*Tournament, error) {
|
||||
if err := draft.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(draft.TeamIDs), Matches: slices.Clone(draft.Matches), Version: 0}
|
||||
for i := range t.Matches {
|
||||
t.Matches[i].SeriesID, t.Matches[i].TeamAID, t.Matches[i].TeamBID, t.Matches[i].WinnerTeamID, t.Matches[i].Series = "", "", "", "", nil
|
||||
}
|
||||
if err := t.Resolve(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.BuildRounds()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *Tournament) Resolve() error {
|
||||
byID := make(map[string]*BracketMatch, len(t.Matches))
|
||||
for i := range t.Matches {
|
||||
byID[t.Matches[i].ID] = &t.Matches[i]
|
||||
}
|
||||
resolve := func(source SlotSource) string {
|
||||
if source.Kind == SlotTeam {
|
||||
return source.TeamID
|
||||
}
|
||||
match := byID[source.MatchID]
|
||||
if match == nil || match.WinnerTeamID == "" {
|
||||
return ""
|
||||
}
|
||||
if source.Kind == SlotWinner {
|
||||
return match.WinnerTeamID
|
||||
}
|
||||
if match.WinnerTeamID == match.TeamAID {
|
||||
return match.TeamBID
|
||||
}
|
||||
return match.TeamAID
|
||||
}
|
||||
for i := range t.Matches {
|
||||
match := &t.Matches[i]
|
||||
match.TeamAID, match.TeamBID = resolve(match.SlotA), resolve(match.SlotB)
|
||||
if match.TeamAID != "" && match.TeamAID == match.TeamBID {
|
||||
return fmt.Errorf("%w: bracket match resolved to the same team", ErrConflict)
|
||||
}
|
||||
}
|
||||
t.BuildRounds()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tournament) ApplySeries(series Series) error {
|
||||
found := false
|
||||
for i := range t.Matches {
|
||||
if t.Matches[i].ID == series.ID || t.Matches[i].SeriesID == series.ID {
|
||||
t.Matches[i].SeriesID = series.ID
|
||||
t.Matches[i].TeamAID, t.Matches[i].TeamBID = series.TeamAID, series.TeamBID
|
||||
t.Matches[i].WinnerTeamID = series.WinnerTeamID
|
||||
copy := series
|
||||
t.Matches[i].Series = ©
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := t.Resolve(); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Version++
|
||||
if t.Complete() {
|
||||
last := slices.MaxFunc(t.Matches, func(a, b BracketMatch) int {
|
||||
if a.Round != b.Round {
|
||||
return a.Round - b.Round
|
||||
}
|
||||
return a.Order - b.Order
|
||||
})
|
||||
t.WinnerTeamID = last.WinnerTeamID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tournament) ReadyMatches() []BracketMatch {
|
||||
out := make([]BracketMatch, 0)
|
||||
for _, match := range t.Matches {
|
||||
if match.TeamAID != "" && match.TeamBID != "" && match.SeriesID == "" {
|
||||
out = append(out, match)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *Tournament) Complete() bool {
|
||||
if len(t.Matches) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, match := range t.Matches {
|
||||
if match.WinnerTeamID == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Tournament) BuildRounds() {
|
||||
if len(t.Matches) == 0 {
|
||||
return
|
||||
}
|
||||
maxRound := 0
|
||||
for _, match := range t.Matches {
|
||||
if match.Round > maxRound {
|
||||
maxRound = match.Round
|
||||
}
|
||||
}
|
||||
t.Rounds = make([][]Series, maxRound+1)
|
||||
matches := slices.Clone(t.Matches)
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
return matches[i].Round < matches[j].Round || matches[i].Round == matches[j].Round && matches[i].Order < matches[j].Order
|
||||
})
|
||||
for _, match := range matches {
|
||||
if match.Series != nil {
|
||||
t.Rounds[match.Round] = append(t.Rounds[match.Round], *match.Series)
|
||||
} else {
|
||||
t.Rounds[match.Round] = append(t.Rounds[match.Round], Series{ID: match.ID, EventID: t.EventID, TournamentID: t.ID, TeamAID: match.TeamAID, TeamBID: match.TeamBID, WinnerTeamID: match.WinnerTeamID, BestOf: 3})
|
||||
}
|
||||
}
|
||||
}
|
||||
51
backend/internal/domain/bracket_test.go
Normal file
51
backend/internal/domain/bracket_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThreeTeamBracketResolvesWinnerAndLoserFeeds(t *testing.T) {
|
||||
draft, err := NewBracketDraft("event", []string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tournament, err := NewGraphTournament("cup", "event", "Cup", *draft)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "b" {
|
||||
t.Fatalf("unexpected first match: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "b", WinnerTeamID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready = tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "b" || ready[0].TeamBID != "c" {
|
||||
t.Fatalf("loser feed was not resolved: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "b", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready = tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "c" {
|
||||
t.Fatalf("final feed was not resolved: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tournament.Complete() || tournament.WinnerTeamID != "c" {
|
||||
t.Fatalf("unexpected champion: %q", tournament.WinnerTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) {
|
||||
draft := BracketDraft{
|
||||
EventID: "event", TeamIDs: []string{"a", "b"},
|
||||
Matches: []BracketMatch{
|
||||
{ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotWinner, MatchID: "m2"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "a"}},
|
||||
{ID: "m2", Round: 0, Order: 1, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "b"}},
|
||||
},
|
||||
}
|
||||
if err := draft.Validate(); err == nil {
|
||||
t.Fatal("expected invalid graph")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
Balancing EventState = "Balancing"
|
||||
RostersDraft EventState = "RostersDraft"
|
||||
RostersConfirmed EventState = "RostersConfirmed"
|
||||
BracketDraftState EventState = "BracketDraft"
|
||||
Live EventState = "Live"
|
||||
Completed EventState = "Completed"
|
||||
Cancelled EventState = "Cancelled"
|
||||
|
||||
@@ -468,12 +468,14 @@ func (s *Series) recalculateWinner() {
|
||||
}
|
||||
|
||||
type Tournament struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Matches []BracketMatch `json:"matches,omitempty"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
||||
|
||||
26
backend/migrations/010_custom_brackets.sql
Normal file
26
backend/migrations/010_custom_brackets.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
|
||||
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
|
||||
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
|
||||
'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed', 'Cancelled'
|
||||
));
|
||||
|
||||
ALTER TABLE tournaments ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_bracket_drafts (
|
||||
event_id text PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
|
||||
body jsonb NOT NULL,
|
||||
version integer NOT NULL DEFAULT 0,
|
||||
confirmed boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +mixmaker Down
|
||||
DROP TABLE IF EXISTS event_bracket_drafts;
|
||||
ALTER TABLE tournaments DROP COLUMN IF EXISTS version;
|
||||
UPDATE events SET state = 'RostersConfirmed' WHERE state = 'BracketDraft';
|
||||
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
|
||||
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
|
||||
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
|
||||
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed', 'Cancelled'
|
||||
));
|
||||
Reference in New Issue
Block a user