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) CreateSession(context.Context, string, string, time.Time) error DeleteSession(context.Context, string) error UpdatePlayer(context.Context, domain.Player) (domain.Player, error) ListPlayers(context.Context) ([]domain.Player, 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) 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) 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) 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) AppendAudit(context.Context, string, string, string, any) error } type Publisher interface{ Publish(topic string, value any) } type Service struct { Store Store Bus Publisher Now func() time.Time } func New(store Store, bus Publisher) *Service { return &Service{Store: store, Bus: bus, 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) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) { if actor.ID != current.AccountID { return domain.Player{}, domain.ErrForbidden } if err := ratings.Validate(); err != nil { return domain.Player{}, err } if displayName != "" { current.DisplayName = displayName } current.Ratings = ratings current.PreferredRoles = preferredRoles current.PreferredPlayerIDs = preferredPlayerIDs current.AvoidedPlayerIDs = 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) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) { if !actor.IsAdmin() { return domain.Event{}, domain.ErrForbidden } if event.RegistrationDeadline.IsZero() { 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) } return out, err } func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) { if !actor.IsAdmin() { return domain.Event{}, domain.ErrForbidden } current, err := s.Store.GetEvent(ctx, eventID) if err != nil { return domain.Event{}, err } if changes.RegistrationDeadline.IsZero() { 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) } 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.IsAdmin() { 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.IsAdmin() && 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) } return out, err } func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error { if !actor.IsAdmin() { 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}) 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.IsAdmin() { 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, } 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) } return player, rsvp, err } func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) { if !actor.IsAdmin() { 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.IsAdmin() { 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.IsAdmin() { 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 CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool { return actor.IsAdmin() || (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.IsAdmin() { 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.IsAdmin() { 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 }