Add Discord event announcement functionality to backend
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

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:
2026-07-19 11:21:11 +03:00
parent b7a78b4384
commit ae19c03542
31 changed files with 1632 additions and 88 deletions

View File

@@ -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
}

View 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),
}
}

View File

@@ -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 = &copy
}
}
}
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 {