diff --git a/backend/internal/adapter/httpapi/server.go b/backend/internal/adapter/httpapi/server.go index 109f224..8324b2b 100644 --- a/backend/internal/adapter/httpapi/server.go +++ b/backend/internal/adapter/httpapi/server.go @@ -65,6 +65,8 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu api.Get("/api/me", s.me) api.Patch("/api/me/player", s.updateProfile) api.Get("/api/players", s.players) + api.Get("/api/accounts", s.accounts) + api.Patch("/api/accounts/{accountID}/moderator", s.setModerator) api.Get("/api/events", s.events) api.Post("/api/events", s.createEvent) api.Get("/api/events/{eventID}", s.getEvent) @@ -84,10 +86,14 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu api.Get("/api/events/{eventID}/roster", s.getRoster) api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance) api.Post("/api/events/{eventID}/roster/swap", s.swapRoster) + api.Post("/api/events/{eventID}/roster/move", s.moveRosterPlayer) + api.Post("/api/events/{eventID}/roster/place-reserve", s.placeReservePlayer) + api.Post("/api/events/{eventID}/roster/remove", s.removeRosterPlayer) api.Post("/api/events/{eventID}/roster/substitute", s.substituteRoster) 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.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage) api.Post("/api/events/{eventID}/start", s.startScrim) api.Put("/api/teams/{teamID}/captain", s.assignCaptain) api.Post("/api/rulesets", s.saveRuleset) @@ -220,8 +226,8 @@ func (s *Server) authenticate(next http.Handler) http.Handler { } func who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) } -func requireAdmin(r *http.Request) error { - if !who(r).account.IsAdmin() { +func requireStaff(r *http.Request) error { + if !who(r).account.IsStaff() { return domain.ErrForbidden } return nil @@ -253,6 +259,22 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) { respond(w, out, err, 200) } +func (s *Server) accounts(w http.ResponseWriter, r *http.Request) { + out, err := s.service.ListAccounts(r.Context(), who(r).account) + respond(w, out, err, http.StatusOK) +} + +func (s *Server) setModerator(w http.ResponseWriter, r *http.Request) { + var in struct { + Moderator bool `json:"moderator"` + } + if !decode(w, r, &in) { + return + } + out, err := s.service.SetModerator(r.Context(), who(r).account, chi.URLParam(r, "accountID"), in.Moderator) + respond(w, out, err, http.StatusOK) +} + func (s *Server) events(w http.ResponseWriter, r *http.Request) { from := time.Unix(0, 0).UTC() if raw := r.URL.Query().Get("from"); raw != "" { @@ -358,7 +380,7 @@ func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) { } func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) { - if err := requireAdmin(r); err != nil { + if err := requireStaff(r); err != nil { writeError(w, err) return } @@ -410,7 +432,7 @@ func (s *Server) coinToss(w http.ResponseWriter, r *http.Request) { } func (s *Server) createMapDraft(w http.ResponseWriter, r *http.Request) { - if err := requireAdmin(r); err != nil { + if err := requireStaff(r); err != nil { writeError(w, err) return } @@ -478,7 +500,7 @@ func (s *Server) mapBan(w http.ResponseWriter, r *http.Request) { } func (s *Server) createHeroDraft(w http.ResponseWriter, r *http.Request) { - if err := requireAdmin(r); err != nil { + if err := requireStaff(r); err != nil { writeError(w, err) return } @@ -532,7 +554,7 @@ func (s *Server) heroBan(w http.ResponseWriter, r *http.Request) { func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error { id := who(r) - if id.account.IsAdmin() { + if id.account.IsStaff() { return nil } teams, err := s.store.ListTeams(r.Context(), eventID) @@ -548,7 +570,7 @@ func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error { } func (s *Server) createTournament(w http.ResponseWriter, r *http.Request) { - if err := requireAdmin(r); err != nil { + if err := requireStaff(r); err != nil { writeError(w, err) return } diff --git a/backend/internal/adapter/httpapi/workflow_handlers.go b/backend/internal/adapter/httpapi/workflow_handlers.go index 17664c0..ddf3bf2 100644 --- a/backend/internal/adapter/httpapi/workflow_handlers.go +++ b/backend/internal/adapter/httpapi/workflow_handlers.go @@ -79,6 +79,48 @@ func (s *Server) swapRoster(w http.ResponseWriter, r *http.Request) { respond(w, out, err, http.StatusOK) } +func (s *Server) moveRosterPlayer(w http.ResponseWriter, r *http.Request) { + var in struct { + FromTeamID string `json:"fromTeamId"` + PlayerID string `json:"playerId"` + ToTeamID string `json:"toTeamId"` + Role domain.Role `json:"role"` + ExpectedVersion int `json:"expectedVersion"` + } + if !decode(w, r, &in) { + return + } + out, err := s.service.MoveRosterPlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.FromTeamID, in.PlayerID, in.ToTeamID, in.Role, in.ExpectedVersion) + respond(w, out, err, http.StatusOK) +} + +func (s *Server) placeReservePlayer(w http.ResponseWriter, r *http.Request) { + var in struct { + TeamID string `json:"teamId"` + ReservePlayerID string `json:"reservePlayerId"` + Role domain.Role `json:"role"` + ExpectedVersion int `json:"expectedVersion"` + } + if !decode(w, r, &in) { + return + } + out, err := s.service.PlaceReservePlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.ReservePlayerID, in.Role, in.ExpectedVersion) + respond(w, out, err, http.StatusOK) +} + +func (s *Server) removeRosterPlayer(w http.ResponseWriter, r *http.Request) { + var in struct { + TeamID string `json:"teamId"` + PlayerID string `json:"playerId"` + ExpectedVersion int `json:"expectedVersion"` + } + if !decode(w, r, &in) { + return + } + out, err := s.service.RemoveRosterPlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.PlayerID, in.ExpectedVersion) + respond(w, out, err, http.StatusOK) +} + func (s *Server) substituteRoster(w http.ResponseWriter, r *http.Request) { s.handleSubstitute(w, r, false) } @@ -126,6 +168,17 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) { respond(w, out, err, http.StatusOK) } +func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) { + var in struct { + ExpectedVersion int `json:"expectedVersion"` + } + if !decode(w, r, &in) { + return + } + out, err := s.service.RevertWorkflowStage(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion) + respond(w, out, err, http.StatusOK) +} + func (s *Server) startScrim(w http.ResponseWriter, r *http.Request) { var in struct { ExpectedVersion int `json:"expectedVersion"` diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go index 1497cb3..8ec1ce0 100644 --- a/backend/internal/adapter/postgres/store.go +++ b/backend/internal/adapter/postgres/store.go @@ -55,7 +55,7 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account err = tx.QueryRow(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE SET username=excluded.username,avatar_url=excluded.avatar_url, - role=CASE WHEN accounts.role='admin' THEN accounts.role ELSE excluded.role END + role=CASE WHEN accounts.role IN ('admin','moderator') THEN accounts.role ELSE excluded.role END RETURNING id,discord_id,username,avatar_url,role,created_at`, account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt). Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt) @@ -84,6 +84,31 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account return account, p, tx.Commit(ctx) } +func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) { + rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`) + if err != nil { + return nil, err + } + defer rows.Close() + accounts := make([]domain.Account, 0) + for rows.Next() { + var account domain.Account + if err := rows.Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt); err != nil { + return nil, err + } + accounts = append(accounts, account) + } + return accounts, rows.Err() +} + +func (s *Store) UpdateAccountRole(ctx context.Context, accountID string, role domain.GlobalRole) (domain.Account, error) { + var account domain.Account + err := s.pool.QueryRow(ctx, `UPDATE accounts SET role=$2 WHERE id=$1 AND role<>'admin' + RETURNING id,discord_id,username,avatar_url,role,created_at`, accountID, role). + Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt) + return account, mapError(err) +} + func (s *Store) CreateSession(ctx context.Context, token, accountID string, expires time.Time) error { _, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires) return err @@ -331,6 +356,21 @@ func (s *Store) GetRoster(ctx context.Context, eventID string) (domain.RosterDra return roster, mapError(err) } +func (s *Store) ResetRoster(ctx context.Context, eventID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err = tx.Exec(ctx, `DELETE FROM event_rosters WHERE event_id=$1`, eventID); err != nil { + return err + } + if _, err = tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil { + return err + } + return tx.Commit(ctx) +} + 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 { diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index e9bf892..d7640f2 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -15,6 +15,8 @@ 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) @@ -27,6 +29,7 @@ type Store interface { 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 StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error DeleteEvent(context.Context, string) error UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error) @@ -73,6 +76,29 @@ func (s *Service) Authenticate(ctx context.Context, session string) (domain.Acco 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 +} + 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 @@ -117,7 +143,7 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu } func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } if event.RegistrationDeadline.IsZero() { @@ -139,7 +165,7 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d } func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } current, err := s.Store.GetEvent(ctx, eventID) @@ -172,7 +198,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer playerID = actorPlayer.ID } if playerID != actorPlayer.ID { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.RSVP{}, domain.ErrForbidden } source = domain.SourceAdmin @@ -184,7 +210,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer if event.State != domain.RegistrationOpen { return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict) } - if !actor.IsAdmin() && s.Now().After(event.RegistrationDeadline) { + 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()} @@ -200,7 +226,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer } func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -219,7 +245,7 @@ func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, e } 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() { + if !actor.IsStaff() { return domain.Player{}, domain.RSVP{}, domain.ErrForbidden } displayName = strings.TrimSpace(displayName) @@ -265,7 +291,7 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e } func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return nil, domain.ErrForbidden } rsvps, err := s.Store.ListRSVPs(ctx, eventID) @@ -290,7 +316,7 @@ func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID str } func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate) error { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.ErrForbidden } if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil { @@ -302,7 +328,7 @@ func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, event } func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamID, playerID string) (domain.Team, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Team{}, domain.ErrForbidden } team, err := s.Store.AssignCaptain(ctx, teamID, playerID) @@ -314,11 +340,11 @@ func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamI } func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool { - return actor.IsAdmin() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID) + 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.IsAdmin() { + if !actor.IsStaff() { return domain.Series{}, domain.ErrForbidden } series, err := s.Store.GetSeries(ctx, seriesID) @@ -340,7 +366,7 @@ func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID, } 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() { + if !actor.IsStaff() { return domain.Series{}, domain.ErrForbidden } series, err := s.Store.GetSeries(ctx, seriesID) diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index 9c23ad5..20baa91 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -19,7 +19,7 @@ type ScrimStart struct { } func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -44,7 +44,7 @@ func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID } func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID string) error { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.ErrForbidden } if err := s.Store.DeleteEvent(ctx, eventID); err != nil { @@ -57,7 +57,7 @@ func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID } func (s *Service) CloseRegistration(ctx context.Context, actor domain.Account, eventID, rulesetID string, expectedVersion int) (domain.Event, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -81,7 +81,7 @@ func (s *Service) CloseRegistration(ctx context.Context, actor domain.Account, e } func (s *Service) GenerateBalance(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (BalanceWorkflow, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return BalanceWorkflow{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -106,7 +106,7 @@ func (s *Service) GenerateBalance(ctx context.Context, actor domain.Account, eve } func (s *Service) SelectWorkflowBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate, expectedVersion int) (domain.RosterDraft, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.RosterDraft{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -137,7 +137,7 @@ func (s *Service) SelectWorkflowBalance(ctx context.Context, actor domain.Accoun } func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID, teamA, playerA, teamB, playerB string, expectedVersion int) (domain.RosterDraft, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.RosterDraft{}, domain.ErrForbidden } roster, err := s.Store.GetRoster(ctx, eventID) @@ -159,8 +159,83 @@ func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID, return roster, err } +func (s *Service) MoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, fromTeamID, playerID, toTeamID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) { + if !actor.IsStaff() { + return domain.RosterDraft{}, domain.ErrForbidden + } + roster, err := s.Store.GetRoster(ctx, eventID) + if err != nil { + return roster, err + } + if roster.Version != expectedVersion { + return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict) + } + if err = roster.MoveToEmpty(fromTeamID, playerID, toTeamID, role); err != nil { + return roster, err + } + return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_moved") +} + +func (s *Service) PlaceReservePlayer(ctx context.Context, actor domain.Account, eventID, teamID, reserveID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) { + if !actor.IsStaff() { + return domain.RosterDraft{}, domain.ErrForbidden + } + roster, err := s.Store.GetRoster(ctx, eventID) + if err != nil { + return roster, err + } + if roster.Version != expectedVersion { + return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict) + } + players, err := s.Store.ListPlayers(ctx) + if err != nil { + return roster, err + } + rating := 0 + for _, player := range players { + if player.ID == reserveID { + rating = ratingForRole(player, role) + break + } + } + if rating == 0 { + return roster, domain.ErrNotFound + } + if err = roster.PlaceReserve(teamID, role, reserveID, rating); err != nil { + return roster, err + } + return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.reserve_placed") +} + +func (s *Service) RemoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) { + if !actor.IsStaff() { + return domain.RosterDraft{}, domain.ErrForbidden + } + roster, err := s.Store.GetRoster(ctx, eventID) + if err != nil { + return roster, err + } + if roster.Version != expectedVersion { + return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict) + } + if err = roster.MoveToReserve(teamID, playerID); err != nil { + return roster, err + } + return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_removed") +} + +func (s *Service) saveRosterChange(ctx context.Context, actor domain.Account, roster domain.RosterDraft, expectedVersion int, action string) (domain.RosterDraft, error) { + out, err := s.Store.SaveRoster(ctx, roster, expectedVersion) + if err == nil { + _ = s.Store.SaveTeams(ctx, roster.EventID, out.Teams) + _ = s.Store.AppendAudit(ctx, actor.ID, action, roster.EventID, out) + s.Bus.Publish("event:"+roster.EventID, out) + } + return out, err +} + func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, eventID, teamID, outgoingID, reserveID string, expectedVersion int, emergency bool) (domain.RosterDraft, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.RosterDraft{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -220,7 +295,7 @@ func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, ev } func (s *Service) SetRosterCaptain(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.RosterDraft{}, domain.ErrForbidden } roster, err := s.Store.GetRoster(ctx, eventID) @@ -253,7 +328,7 @@ func (s *Service) SetRosterCaptain(ctx context.Context, actor domain.Account, ev } func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion, expectedRosterVersion int) (domain.Event, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) @@ -287,8 +362,59 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even return event, 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 + } + event, err := s.Store.GetEvent(ctx, eventID) + if err != nil { + return event, err + } + oldVersion := event.Version + var previous domain.EventState + switch event.State { + case domain.RegistrationClosed: + previous = domain.RegistrationOpen + case domain.Balancing: + previous = domain.RegistrationClosed + case domain.RostersDraft: + previous = domain.Balancing + case domain.RostersConfirmed: + previous = domain.RostersDraft + default: + return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict) + } + if err = event.Transition([]domain.EventState{event.State}, previous, expectedVersion); err != nil { + return event, err + } + if previous == domain.Balancing { + if err = s.Store.ResetRoster(ctx, eventID); err != nil { + return event, err + } + } + if previous == domain.RostersDraft { + roster, rosterErr := s.Store.GetRoster(ctx, eventID) + if rosterErr != nil { + return event, rosterErr + } + rosterVersion := roster.Version + roster.Confirmed = false + roster.Version++ + if _, rosterErr = s.Store.SaveRoster(ctx, roster, rosterVersion); rosterErr != nil { + return event, rosterErr + } + } + event.UpdatedAt = s.Now() + event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion) + if err == nil { + _ = s.Store.AppendAudit(ctx, actor.ID, "workflow.reverted", eventID, map[string]domain.EventState{"state": previous}) + s.Bus.Publish("event:"+eventID, event) + } + return event, err +} + func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (ScrimStart, error) { - if !actor.IsAdmin() { + if !actor.IsStaff() { return ScrimStart{}, domain.ErrForbidden } event, err := s.Store.GetEvent(ctx, eventID) diff --git a/backend/internal/domain/event_workflow.go b/backend/internal/domain/event_workflow.go index 95d2987..97ae90b 100644 --- a/backend/internal/domain/event_workflow.go +++ b/backend/internal/domain/event_workflow.go @@ -49,11 +49,17 @@ func (r RosterDraft) Validate(requireCaptains bool) error { } counts := map[Role]int{} for _, slot := range team.Slots { - if slot.PlayerID == "" || seen[slot.PlayerID] { + counts[slot.Role]++ + if slot.PlayerID == "" { + if requireCaptains { + return fmt.Errorf("%w: every roster slot must be filled", ErrInvalid) + } + continue + } + if seen[slot.PlayerID] { return fmt.Errorf("%w: roster players must be unique", ErrInvalid) } seen[slot.PlayerID] = true - counts[slot.Role]++ } if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 { return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid) @@ -127,6 +133,88 @@ func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating in return ErrNotFound } +func (r *RosterDraft) PlaceReserve(teamID string, role Role, reserveID string, rating int) error { + if r.Confirmed { + return fmt.Errorf("%w: rosters are locked", ErrConflict) + } + reserveIndex := slices.Index(r.Reserve, reserveID) + if reserveIndex < 0 { + return fmt.Errorf("%w: player is not in reserve", ErrInvalid) + } + for teamIndex := range r.Teams { + if r.Teams[teamIndex].ID != teamID { + continue + } + for slotIndex := range r.Teams[teamIndex].Slots { + slot := &r.Teams[teamIndex].Slots[slotIndex] + if slot.Role == role && slot.PlayerID == "" { + slot.PlayerID, slot.Rating = reserveID, rating + r.Reserve = slices.Delete(r.Reserve, reserveIndex, reserveIndex+1) + r.Version++ + return r.Validate(false) + } + } + } + return fmt.Errorf("%w: empty role slot not found", ErrNotFound) +} + +func (r *RosterDraft) MoveToEmpty(fromTeamID, playerID, toTeamID string, role Role) error { + if r.Confirmed { + return fmt.Errorf("%w: rosters are locked", ErrConflict) + } + var source, target *Slot + var sourceTeam *Team + for teamIndex := range r.Teams { + team := &r.Teams[teamIndex] + for slotIndex := range team.Slots { + slot := &team.Slots[slotIndex] + if team.ID == fromTeamID && slot.PlayerID == playerID { + source, sourceTeam = slot, team + } + if team.ID == toTeamID && slot.Role == role && slot.PlayerID == "" { + target = slot + } + } + } + if source == nil || target == nil { + return ErrNotFound + } + if source.Role != target.Role { + return fmt.Errorf("%w: only equal roles can be moved", ErrInvalid) + } + target.PlayerID, target.Rating = source.PlayerID, source.Rating + source.PlayerID, source.Rating = "", 0 + if sourceTeam.CaptainPlayerID == playerID { + sourceTeam.CaptainPlayerID = "" + } + r.Version++ + return r.Validate(false) +} + +func (r *RosterDraft) MoveToReserve(teamID, playerID string) error { + if r.Confirmed { + return fmt.Errorf("%w: rosters are locked", ErrConflict) + } + for teamIndex := range r.Teams { + if r.Teams[teamIndex].ID != teamID { + continue + } + for slotIndex := range r.Teams[teamIndex].Slots { + slot := &r.Teams[teamIndex].Slots[slotIndex] + if slot.PlayerID == playerID { + slot.PlayerID, slot.Rating = "", 0 + r.Reserve = append(r.Reserve, playerID) + if r.Teams[teamIndex].CaptainPlayerID == playerID { + r.Teams[teamIndex].CaptainPlayerID = "" + } + r.Version++ + return r.Validate(false) + } + } + } + return ErrNotFound +} + func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error { confirmed := r.Confirmed r.Confirmed = false diff --git a/backend/internal/domain/model.go b/backend/internal/domain/model.go index 1c02375..5873e88 100644 --- a/backend/internal/domain/model.go +++ b/backend/internal/domain/model.go @@ -22,8 +22,9 @@ type ID string type GlobalRole string const ( - RolePlayer GlobalRole = "player" - RoleAdmin GlobalRole = "admin" + RolePlayer GlobalRole = "player" + RoleModerator GlobalRole = "moderator" + RoleAdmin GlobalRole = "admin" ) type Account struct { @@ -36,6 +37,7 @@ type Account struct { } func (a Account) IsAdmin() bool { return a.Role == RoleAdmin } +func (a Account) IsStaff() bool { return a.Role == RoleAdmin || a.Role == RoleModerator } type Ratings struct { Tank int `json:"tank"` diff --git a/backend/internal/domain/workflow_test.go b/backend/internal/domain/workflow_test.go index b641ea2..5688d88 100644 --- a/backend/internal/domain/workflow_test.go +++ b/backend/internal/domain/workflow_test.go @@ -17,6 +17,16 @@ func TestEventWorkflowRejectsStaleVersion(t *testing.T) { } } +func TestModeratorIsStaffButNotAdministrator(t *testing.T) { + account := Account{Role: RoleModerator} + if !account.IsStaff() { + t.Fatal("moderator should have operational staff permissions") + } + if account.IsAdmin() { + t.Fatal("moderator must not have administrator role-management permission") + } +} + func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) { roster := testRoster() if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) { @@ -36,6 +46,35 @@ func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) { } } +func TestRosterDragOperationsPreserveEmptyRoleSlots(t *testing.T) { + roster := testRoster() + roster.Teams[0].CaptainPlayerID = "a-t" + if err := roster.MoveToReserve("a", "a-t"); err != nil { + t.Fatal(err) + } + if roster.Teams[0].Slots[0].PlayerID != "" || roster.Teams[0].Slots[0].Role != Tank { + t.Fatal("removed tank did not leave an identifiable empty tank slot") + } + if roster.Teams[0].CaptainPlayerID != "" { + t.Fatal("removed captain was retained") + } + if err := roster.Validate(true); !errors.Is(err, ErrInvalid) { + t.Fatalf("incomplete roster was confirmable: %v", err) + } + if err := roster.PlaceReserve("a", Tank, "reserve", 31); err != nil { + t.Fatal(err) + } + if err := roster.MoveToReserve("b", "b-t"); err != nil { + t.Fatal(err) + } + if err := roster.MoveToEmpty("a", "reserve", "b", Tank); err != nil { + t.Fatal(err) + } + if roster.Teams[1].Slots[0].PlayerID != "reserve" { + t.Fatal("dragging into an empty role slot did not move the player") + } +} + func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) { rules := testRules() series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules) diff --git a/backend/migrations/009_moderator_role.sql b/backend/migrations/009_moderator_role.sql new file mode 100644 index 0000000..b5b7bb9 --- /dev/null +++ b/backend/migrations/009_moderator_role.sql @@ -0,0 +1,10 @@ +-- +mixmaker Up +ALTER TABLE accounts + DROP CONSTRAINT IF EXISTS accounts_role_check, + ADD CONSTRAINT accounts_role_check CHECK (role IN ('admin', 'moderator', 'player')); + +-- +mixmaker Down +UPDATE accounts SET role = 'player' WHERE role = 'moderator'; +ALTER TABLE accounts + DROP CONSTRAINT IF EXISTS accounts_role_check, + ADD CONSTRAINT accounts_role_check CHECK (role IN ('admin', 'player')); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d117a9c..653dfa8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react' import { CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound, @@ -11,7 +11,7 @@ import { } from '@tanstack/react-router' import { api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent, - type Player, type PlayerRole, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team, + type Player, type PlayerRole, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team, } from './api/client' import { demoBalanceCandidates, demoBracket, demoEvents, demoProfile, @@ -22,6 +22,7 @@ import { useLanguage } from './i18n-context' import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks' type RouterContext = { session: Session | null } +const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } }) const rootRoute = createRootRouteWithContext()({ component: Outlet }) const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage }) @@ -39,7 +40,7 @@ const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path: const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage }) const adminRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage, - beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) }, + beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) }, }) const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([ indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute, @@ -85,6 +86,9 @@ function AppShell() { } else if (message.topic === 'players') { void client.invalidateQueries({ queryKey: ['players'] }) void client.invalidateQueries({ queryKey: ['profile'] }) + } else if (message.topic === 'accounts') { + void client.invalidateQueries({ queryKey: ['session'] }) + void client.invalidateQueries({ queryKey: ['accounts'] }) } }, setRealtimeStatus), [client]) const logout = useMutation({ @@ -95,8 +99,8 @@ function AppShell() { }, }) const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM' - const navItems = session.data?.account.role === 'admin' - ? [...mainNavItems, { to: '/admin' as const, label: 'Admin', icon: Shield }] + const navItems = isStaff(session.data?.account.role) + ? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }] : mainNavItems return
@@ -123,7 +127,7 @@ function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; } function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return {children} } function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return

{label}

Syncing with the server.

} -function ErrorState({ retry }: { retry?: () => void }) { return

Couldn’t load this view

Your data is safe. Check your connection and try again.

{retry && }
} +function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return

Couldn’t load this view

{message || 'Your data is safe. Check your connection and try again.'}

{retry && }
} function EventsPage() { const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming') @@ -132,14 +136,26 @@ function EventsPage() { if (events.isError) return void events.refetch()} /> if (!events.data) return const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data + const liveEvent = events.data.find((event) => event.workflowState === 'Live') return
} /> - {demoMode &&
Live now

{demoEvents[2].title}

Semifinal 1 · Map 2 draft in progress

EMBER1:0AZURE
Watch live
} + {liveEvent && }

Coming up

{visible.length} scheduled events

{visible.length === 0 ?

No events yet

New community nights will appear here.

:
{visible.map((event) => )}
}
} +function FeaturedLiveEvent({ event }: { event: MixEvent }) { + const series = useQuery({ + queryKey: ['series', event.activeSeriesId], + queryFn: () => api.series(event.activeSeriesId!), + enabled: Boolean(event.activeSeriesId), + initialData: demoMode && event.activeSeriesId === demoSeries.id ? demoSeries : undefined, + }) + const match = series.data + return
Live now

{event.title}

{match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}

{match &&
{match.teamAlpha.name}{match.score.alpha}:{match.score.beta}{match.teamBeta.name}
}{event.activeSeriesId ? Watch live : Open bracket }
+} + function EventCard({ event }: { event: MixEvent }) { const date = new Date(event.startsAt) const statusBadge = event.status === 'cancelled' ? Cancelled @@ -175,7 +191,7 @@ function EventPage() { const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName)) return
{event.status !== 'cancelled' && event.activeSeriesId && Open live series}{event.status !== 'cancelled' && event.tournamentId && Open bracket}{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}} />
Starts{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}

Are you playing?

{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}

{(Object.keys(labels) as RsvpStatus[]).map((status) => )}
{rsvpMutation.isSuccess &&
Response saved · Server confirmed just now
}{rsvpMutation.isError &&

{rsvpMutation.error.message}

}
- +

Registered players

Everyone can see who responded and their current status.

{attendees.length === 0 ?

No responses yet.

:
{attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()}{registration.player.displayName}{labels[registration.status]}
)}
}
} @@ -233,17 +249,32 @@ function ProfilePage() { return
{profile.data.displayName.slice(0, 2).toUpperCase()}

{profile.data.displayName}

{profile.data.battleTag}

Connected via Discord

Competitive ranks

Select your current Overwatch rank for each role.

Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}
{roles.map((role) => )}

Preferred roles

Choose every role you enjoy playing.

{roles.map((role) => )}

Preferred teammates

Choose up to 3 players. The balancer treats these as preferences, not guarantees.

{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return })}

Avoided teammates

Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.

{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return })}

Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}

} -type AdminTab = 'events' | 'participants' | 'balance' +type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' function AdminPage() { + const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity }) const [tab, setTab] = useState('events') const [createMode, setCreateMode] = useState(false) const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined }) const [selectedId, setSelectedId] = useState() const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0] useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId]) - if (events.isLoading) return - if (events.isError) return void events.refetch()} /> - return
{events.data && events.data.length > 0 && }} />
{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => )}
{tab === 'events' && { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && }{tab === 'balance' && selected && }
+ if (events.isLoading || session.isLoading) return + if (events.isError || session.isError) return { void events.refetch(); void session.refetch() }} /> + const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const] : [])] + return
{events.data && events.data.length > 0 && } : undefined} />
{tabs.map((item) => )}
{tab === 'events' && { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && }{tab === 'balance' && selected && }{tab === 'moderators' && session.data?.account.role === 'admin' && }
+} + +function ModeratorManagement() { + const client = useQueryClient() + const accounts = useQuery({ queryKey: ['accounts'], queryFn: api.accounts }) + const update = useMutation({ + mutationFn: ({ accountId, moderator }: { accountId: string; moderator: boolean }) => api.setModerator(accountId, moderator), + onSuccess: (account) => client.setQueryData(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)), + onError: () => void accounts.refetch(), + }) + if (accounts.isLoading) return + if (accounts.isError || !accounts.data) return void accounts.refetch()} /> + return

Moderators

Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.

{accounts.data.filter((account) => account.role === 'moderator').length} active
{accounts.data.map((account) =>
{account.username.slice(0, 2).toUpperCase()}{account.username}Discord ID · {account.discordId}{account.role === 'admin' ? Administrator : }
)}
{update.isError &&

{update.error.message}

}
} function toLocalInput(value: string) { @@ -253,7 +284,7 @@ function toLocalInput(value: string) { function eventInput(event?: MixEvent): EventInput { const start = new Date(Date.now() + 86_400_000) - const end = new Date(start.getTime() + 4 * 3_600_000) + const end = new Date(start.getTime() + 2 * 3_600_000) const deadline = new Date(start.getTime() - 3_600_000) return { name: event?.title ?? '', @@ -305,10 +336,19 @@ function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreat }, }) const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value })) + const setStart = (value: string) => setForm((current) => { + if (event) return { ...current, startsAt: value } + const start = new Date(value) + return { + ...current, + startsAt: value, + endsAt: Number.isNaN(start.getTime()) ? current.endsAt : toLocalInput(new Date(start.getTime() + 2 * 3_600_000).toISOString()), + } + }) return
{ e.preventDefault(); save.mutate() }}>

{event ? 'Event settings' : 'New event'}

Scheduling and registration

{event &&
{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}
}
-
+