package application import ( "context" "crypto/rand" "encoding/hex" "fmt" "strings" "time" "mixmaker/backend/internal/domain" ) type Store interface { Ready(context.Context) error UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error) AccountBySession(context.Context, string) (domain.Account, domain.Player, error) ListAccounts(context.Context) ([]domain.Account, error) UpdateAccountRole(context.Context, string, domain.GlobalRole) (domain.Account, error) CreateSession(context.Context, string, string, time.Time) error DeleteSession(context.Context, string) error UpdatePlayer(context.Context, domain.Player) (domain.Player, error) GetPlayer(context.Context, string) (domain.Player, error) ListPlayers(context.Context) ([]domain.Player, error) EnqueueDiscordRoleSync(context.Context, string, string, int64) error CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error) CreateEvent(context.Context, domain.Event) (domain.Event, error) GetEvent(context.Context, string) (domain.Event, error) UpdateEvent(context.Context, domain.Event) (domain.Event, error) ListEvents(context.Context, time.Time) ([]domain.Event, error) SaveEventWorkflow(context.Context, domain.Event, int) (domain.Event, error) 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) ListRSVPs(context.Context, string) ([]domain.RSVP, error) DeleteRSVP(context.Context, string, string) error SaveTeams(context.Context, string, []domain.Team) error ListTeams(context.Context, string) ([]domain.Team, error) GetTeam(context.Context, string) (domain.Team, error) RenameTeam(context.Context, string, string) (domain.Team, error) AssignCaptain(context.Context, string, string) (domain.Team, error) SaveRuleset(context.Context, domain.Ruleset) (domain.Ruleset, error) ListRulesets(context.Context) ([]domain.Ruleset, error) GetRuleset(context.Context, string) (domain.Ruleset, error) SaveSeries(context.Context, domain.Series) (domain.Series, error) GetSeries(context.Context, string) (domain.Series, error) ListSeriesByEvent(context.Context, string) ([]domain.Series, error) SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error) GetTournament(context.Context, string) (domain.Tournament, error) GetTournamentByEvent(context.Context, string) (domain.Tournament, error) SaveDraft(context.Context, string, string, any, int) error GetDraft(context.Context, string, any) (string, int, error) GetCommunitySettings(context.Context) (domain.CommunitySettings, error) UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error) AppendAudit(context.Context, string, string, string, any) error } type Publisher interface{ Publish(topic string, value any) } type EventAnnouncer interface { AnnounceEventCreated(context.Context, domain.Event, bool) error } type CreateEventOptions struct { PingEveryone bool } func (s *Service) enqueueDiscordRoleSync(ctx context.Context, eventID, action string) { _ = s.Store.EnqueueDiscordRoleSync(ctx, eventID, action, s.Now().UnixNano()) } 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 { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } func (s *Service) Authenticate(ctx context.Context, session string) (domain.Account, domain.Player, error) { if session == "" { return domain.Account{}, domain.Player{}, domain.ErrUnauthorized } return s.Store.AccountBySession(ctx, session) } func (s *Service) ListAccounts(ctx context.Context, actor domain.Account) ([]domain.Account, error) { if !actor.IsAdmin() { return nil, domain.ErrForbidden } return s.Store.ListAccounts(ctx) } func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accountID string, moderator bool) (domain.Account, error) { if !actor.IsAdmin() { return domain.Account{}, domain.ErrForbidden } role := domain.RolePlayer if moderator { role = domain.RoleModerator } account, err := s.Store.UpdateAccountRole(ctx, accountID, role) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "account.role_changed", accountID, map[string]domain.GlobalRole{"role": role}) s.Bus.Publish("accounts", account) } return account, err } type UpdateOwnProfileInput struct { DisplayName *string BattleTag *string Ratings domain.Ratings PreferredRoles []domain.Role PreferredPlayerIDs []string AvoidedPlayerIDs []string } func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) { if actor.ID != current.AccountID { return domain.Player{}, domain.ErrForbidden } if err := input.Ratings.Validate(); err != nil { return domain.Player{}, err } if input.DisplayName != nil { current.DisplayName = *input.DisplayName } if input.BattleTag != nil { current.BattleTag = *input.BattleTag } if err := current.ValidateIdentity(); err != nil { return domain.Player{}, err } current.Ratings = input.Ratings current.PreferredRoles = input.PreferredRoles current.PreferredPlayerIDs = input.PreferredPlayerIDs current.AvoidedPlayerIDs = input.AvoidedPlayerIDs current.UpdatedAt = s.Now() if err := current.ValidatePreferences(); err != nil { return domain.Player{}, err } players, err := s.Store.ListPlayers(ctx) if err != nil { return domain.Player{}, err } knownPlayers := make(map[string]bool, len(players)) for _, player := range players { knownPlayers[player.ID] = true } for _, playerID := range current.PreferredPlayerIDs { if !knownPlayers[playerID] { return domain.Player{}, fmt.Errorf("%w: preferred teammate does not exist", domain.ErrInvalid) } } for _, playerID := range current.AvoidedPlayerIDs { if !knownPlayers[playerID] { return domain.Player{}, fmt.Errorf("%w: avoided teammate does not exist", domain.ErrInvalid) } } out, err := s.Store.UpdatePlayer(ctx, current) if err == nil { s.Bus.Publish("players", out) } return out, err } func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) { player, err := s.Store.GetPlayer(ctx, playerID) if err != nil { return domain.PlayerProfile{}, err } events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC()) if err != nil { return domain.PlayerProfile{}, err } matches := make([]domain.PlayerSeriesContext, 0) for _, event := range events { if event.State != domain.Completed { continue } teams, listErr := s.Store.ListTeams(ctx, event.ID) if listErr != nil { return domain.PlayerProfile{}, listErr } var ownTeam *domain.Team for index := range teams { for _, slot := range teams[index].Slots { if slot.PlayerID == playerID { ownTeam = &teams[index] break } } } if ownTeam == nil { continue } series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID) if listErr != nil { return domain.PlayerProfile{}, listErr } byID := make(map[string]domain.Team, len(teams)) for _, team := range teams { byID[team.ID] = team } for _, item := range series { teamA, okA := byID[item.TeamAID] teamB, okB := byID[item.TeamBID] if okA && okB { matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB}) } } } return domain.BuildPlayerProfile(player, matches, 10), nil } func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) { return s.Store.GetCommunitySettings(ctx) } func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) { if !actor.IsAdmin() { return domain.CommunitySettings{}, domain.ErrForbidden } settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID} if err := settings.Validate(); err != nil { return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err) } out, err := s.Store.UpdateCommunitySettings(ctx, settings) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out) s.Bus.Publish("community-settings", out) } return out, err } func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) { if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } event.RegistrationDeadline = event.StartsAt event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now() event.State, event.Version = domain.RegistrationOpen, 0 if event.RulesetID == "" { event.RulesetID = "standard-control-hybrid-control" } if err := event.Validate(); err != nil { return domain.Event{}, err } 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, options.PingEveryone) cancel() } } return out, err } func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) { if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } current, err := s.Store.GetEvent(ctx, eventID) if err != nil { return domain.Event{}, err } changes.RegistrationDeadline = changes.StartsAt current.Name = changes.Name current.Description = changes.Description current.StartsAt = changes.StartsAt current.EndsAt = changes.EndsAt current.RegistrationDeadline = changes.RegistrationDeadline current.UpdatedAt = s.Now() if err := current.Validate(); err != nil { return domain.Event{}, err } out, err := s.Store.UpdateEvent(ctx, current) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "event.updated", eventID, out) s.Bus.Publish("events", out) s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return out, err } func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer domain.Player, eventID, playerID string, status domain.RSVPStatus) (domain.RSVP, error) { source := domain.SourcePlayer if playerID == "" { playerID = actorPlayer.ID } if playerID != actorPlayer.ID { if !actor.IsStaff() { return domain.RSVP{}, domain.ErrForbidden } source = domain.SourceAdmin } event, err := s.Store.GetEvent(ctx, eventID) if err != nil { return domain.RSVP{}, err } if event.State != domain.RegistrationOpen { return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict) } if !actor.IsStaff() && s.Now().After(event.RegistrationDeadline) { return domain.RSVP{}, fmt.Errorf("%w: registration deadline has passed", domain.ErrConflict) } rsvp := domain.RSVP{EventID: eventID, PlayerID: playerID, ActorAccountID: actor.ID, Status: status, Source: source, UpdatedAt: s.Now()} if err := rsvp.Validate(); err != nil { return domain.RSVP{}, err } out, err := s.Store.UpsertRSVP(ctx, rsvp) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "rsvp.set", eventID, out) s.Bus.Publish("event:"+eventID, out) s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return out, err } func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error { if !actor.IsStaff() { return domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) if err != nil { return err } if event.State != domain.RegistrationOpen { return fmt.Errorf("%w: registration is closed", domain.ErrConflict) } if err := s.Store.DeleteRSVP(ctx, eventID, playerID); err != nil { return err } _ = s.Store.AppendAudit(ctx, actor.ID, "participant.removed", eventID, map[string]string{"playerId": playerID}) s.Bus.Publish("event:"+eventID, map[string]string{"removedPlayerId": playerID}) s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) return nil } func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) { if !actor.IsStaff() { return domain.Player{}, domain.RSVP{}, domain.ErrForbidden } displayName = strings.TrimSpace(displayName) if displayName == "" { return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: display name is required", domain.ErrInvalid) } if err := ratings.Validate(); err != nil { return domain.Player{}, domain.RSVP{}, err } event, err := s.Store.GetEvent(ctx, eventID) if err != nil { return domain.Player{}, domain.RSVP{}, err } if event.State != domain.RegistrationOpen { return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict) } now := s.Now() player := domain.Player{ ID: NewID(), DisplayName: displayName, Ratings: ratings, CreatedAt: now, UpdatedAt: now, } if err := player.ValidateIdentity(); err != nil { return domain.Player{}, domain.RSVP{}, err } rsvp := domain.RSVP{ EventID: eventID, PlayerID: player.ID, ActorAccountID: actor.ID, Status: status, Source: domain.SourceAdmin, UpdatedAt: now, } if err := rsvp.Validate(); err != nil { return domain.Player{}, domain.RSVP{}, err } player, rsvp, err = s.Store.CreateParticipant(ctx, player, rsvp) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp}) s.Bus.Publish("event:"+eventID, rsvp) s.Bus.Publish("players", player) s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return player, rsvp, err } func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) { if !actor.IsStaff() { return nil, domain.ErrForbidden } rsvps, err := s.Store.ListRSVPs(ctx, eventID) if err != nil { return nil, err } players, err := s.Store.ListPlayers(ctx) if err != nil { return nil, err } going := map[string]bool{} for _, r := range rsvps { going[r.PlayerID] = r.Status == domain.Going } eligible := players[:0] for _, p := range players { if going[p.ID] { eligible = append(eligible, p) } } return domain.Balance(eventID, eligible) } func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate) error { if !actor.IsStaff() { return domain.ErrForbidden } if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil { return err } _ = s.Store.AppendAudit(ctx, actor.ID, "balance.selected", eventID, candidate) s.Bus.Publish("event:"+eventID, candidate.Teams) return nil } func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamID, playerID string) (domain.Team, error) { if !actor.IsStaff() { return domain.Team{}, domain.ErrForbidden } team, err := s.Store.AssignCaptain(ctx, teamID, playerID) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "captain.assigned", teamID, map[string]string{"playerId": playerID}) s.Bus.Publish("team:"+teamID, team) } return team, err } func (s *Service) RenameTeam(ctx context.Context, actor domain.Account, player domain.Player, teamID, name string) (domain.Team, error) { team, err := s.Store.GetTeam(ctx, teamID) if err != nil { return team, err } event, err := s.Store.GetEvent(ctx, team.EventID) if err != nil { return team, err } if event.State != domain.Live { return team, fmt.Errorf("%w: team names can only be changed during a live scrim", domain.ErrConflict) } if !CanActForTeam(actor, player, team) { return team, domain.ErrForbidden } name = strings.TrimSpace(name) if len([]rune(name)) < 2 || len([]rune(name)) > 32 { return team, fmt.Errorf("%w: team name must contain 2 to 32 characters", domain.ErrInvalid) } team, err = s.Store.RenameTeam(ctx, teamID, name) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "team.renamed", teamID, map[string]string{"name": name}) s.Bus.Publish("team:"+teamID, team) s.Bus.Publish("event:"+team.EventID, team) s.enqueueDiscordRoleSync(ctx, team.EventID, DiscordRoleActionReconcile) } return team, err } func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool { return actor.IsStaff() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID) } func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) { if !actor.IsStaff() { return domain.Series{}, domain.ErrForbidden } series, err := s.Store.GetSeries(ctx, seriesID) if err != nil { return domain.Series{}, err } if series.Version != expectedVersion { return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict) } if err := series.RecordResult(domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil { return domain.Series{}, err } out, err := s.Store.SaveSeries(ctx, series) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "series.map_recorded", seriesID, out.Results[len(out.Results)-1]) s.Bus.Publish("series:"+seriesID, out) } return out, err } func (s *Service) CorrectMap(ctx context.Context, actor domain.Account, seriesID string, resultIndex int, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) { if !actor.IsStaff() { return domain.Series{}, domain.ErrForbidden } series, err := s.Store.GetSeries(ctx, seriesID) if err != nil { return domain.Series{}, err } if series.Version != expectedVersion { return domain.Series{}, fmt.Errorf("%w: stale series version", domain.ErrConflict) } if err := series.CorrectResult(resultIndex, domain.MapResult{MapName: mapName, Outcome: outcome, ActorAccountID: actor.ID, RecordedAt: s.Now()}); err != nil { return domain.Series{}, err } out, err := s.Store.SaveSeries(ctx, series) if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "series.map_corrected", seriesID, out.Results[len(out.Results)-1]) s.Bus.Publish("series:"+seriesID, out) } return out, err }