Implement account management features in API, including endpoints for listing accounts and updating account roles. Introduce moderator role with associated permissions, and refactor access control checks to accommodate staff roles. Update database schema to support new role constraints and enhance frontend navigation for staff access.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:32:15 +03:00
parent 4b889fb8a0
commit 1a5a39baf0
18 changed files with 726 additions and 74 deletions

View File

@@ -65,6 +65,8 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
api.Get("/api/me", s.me) api.Get("/api/me", s.me)
api.Patch("/api/me/player", s.updateProfile) api.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players) 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.Get("/api/events", s.events)
api.Post("/api/events", s.createEvent) api.Post("/api/events", s.createEvent)
api.Get("/api/events/{eventID}", s.getEvent) 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.Get("/api/events/{eventID}/roster", s.getRoster)
api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance) api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance)
api.Post("/api/events/{eventID}/roster/swap", s.swapRoster) 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/substitute", s.substituteRoster)
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute) api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain) api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters) 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.Post("/api/events/{eventID}/start", s.startScrim)
api.Put("/api/teams/{teamID}/captain", s.assignCaptain) api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
api.Post("/api/rulesets", s.saveRuleset) 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 who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) }
func requireAdmin(r *http.Request) error { func requireStaff(r *http.Request) error {
if !who(r).account.IsAdmin() { if !who(r).account.IsStaff() {
return domain.ErrForbidden return domain.ErrForbidden
} }
return nil return nil
@@ -253,6 +259,22 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200) 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) { func (s *Server) events(w http.ResponseWriter, r *http.Request) {
from := time.Unix(0, 0).UTC() from := time.Unix(0, 0).UTC()
if raw := r.URL.Query().Get("from"); raw != "" { 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) { 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) writeError(w, err)
return 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) { 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) writeError(w, err)
return 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) { 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) writeError(w, err)
return 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 { func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
id := who(r) id := who(r)
if id.account.IsAdmin() { if id.account.IsStaff() {
return nil return nil
} }
teams, err := s.store.ListTeams(r.Context(), eventID) 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) { 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) writeError(w, err)
return return
} }

View File

@@ -79,6 +79,48 @@ func (s *Server) swapRoster(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, http.StatusOK) 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) { func (s *Server) substituteRoster(w http.ResponseWriter, r *http.Request) {
s.handleSubstitute(w, r, false) 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) 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) { func (s *Server) startScrim(w http.ResponseWriter, r *http.Request) {
var in struct { var in struct {
ExpectedVersion int `json:"expectedVersion"` ExpectedVersion int `json:"expectedVersion"`

View File

@@ -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) 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 VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE
SET username=excluded.username,avatar_url=excluded.avatar_url, 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`, RETURNING id,discord_id,username,avatar_url,role,created_at`,
account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt). account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt).
Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.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) 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 { 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) _, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires)
return err return err
@@ -331,6 +356,21 @@ func (s *Store) GetRoster(ctx context.Context, eventID string) (domain.RosterDra
return roster, mapError(err) 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 { 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) tx, err := s.pool.Begin(ctx)
if err != nil { if err != nil {

View File

@@ -15,6 +15,8 @@ type Store interface {
Ready(context.Context) error Ready(context.Context) error
UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error) UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error)
AccountBySession(context.Context, string) (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 CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, 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) SaveEventWorkflow(context.Context, domain.Event, int) (domain.Event, error)
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error) SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
GetRoster(context.Context, string) (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 StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
DeleteEvent(context.Context, string) error DeleteEvent(context.Context, string) error
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, 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) 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) { 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 { if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden 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) { 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 return domain.Event{}, domain.ErrForbidden
} }
if event.RegistrationDeadline.IsZero() { 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) { 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 return domain.Event{}, domain.ErrForbidden
} }
current, err := s.Store.GetEvent(ctx, eventID) 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 playerID = actorPlayer.ID
} }
if playerID != actorPlayer.ID { if playerID != actorPlayer.ID {
if !actor.IsAdmin() { if !actor.IsStaff() {
return domain.RSVP{}, domain.ErrForbidden return domain.RSVP{}, domain.ErrForbidden
} }
source = domain.SourceAdmin source = domain.SourceAdmin
@@ -184,7 +210,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
if event.State != domain.RegistrationOpen { if event.State != domain.RegistrationOpen {
return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict) 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) 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()} 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 { func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error {
if !actor.IsAdmin() { if !actor.IsStaff() {
return domain.ErrForbidden return domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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) { 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 return domain.Player{}, domain.RSVP{}, domain.ErrForbidden
} }
displayName = strings.TrimSpace(displayName) 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) { 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 return nil, domain.ErrForbidden
} }
rsvps, err := s.Store.ListRSVPs(ctx, eventID) 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 { 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 return domain.ErrForbidden
} }
if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil { 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) { 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 return domain.Team{}, domain.ErrForbidden
} }
team, err := s.Store.AssignCaptain(ctx, teamID, playerID) 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 { 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) { 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 return domain.Series{}, domain.ErrForbidden
} }
series, err := s.Store.GetSeries(ctx, seriesID) 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) { 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 return domain.Series{}, domain.ErrForbidden
} }
series, err := s.Store.GetSeries(ctx, seriesID) series, err := s.Store.GetSeries(ctx, seriesID)

View File

@@ -19,7 +19,7 @@ type ScrimStart struct {
} }
func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) { 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 return domain.Event{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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 { func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID string) error {
if !actor.IsAdmin() { if !actor.IsStaff() {
return domain.ErrForbidden return domain.ErrForbidden
} }
if err := s.Store.DeleteEvent(ctx, eventID); err != nil { 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) { 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 return domain.Event{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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) { 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 return BalanceWorkflow{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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) { 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 return domain.RosterDraft{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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) { 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 return domain.RosterDraft{}, domain.ErrForbidden
} }
roster, err := s.Store.GetRoster(ctx, eventID) 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 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) { 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 return domain.RosterDraft{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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) { 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 return domain.RosterDraft{}, domain.ErrForbidden
} }
roster, err := s.Store.GetRoster(ctx, eventID) 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) { 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 return domain.Event{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) 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 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) { 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 return ScrimStart{}, domain.ErrForbidden
} }
event, err := s.Store.GetEvent(ctx, eventID) event, err := s.Store.GetEvent(ctx, eventID)

View File

@@ -49,11 +49,17 @@ func (r RosterDraft) Validate(requireCaptains bool) error {
} }
counts := map[Role]int{} counts := map[Role]int{}
for _, slot := range team.Slots { 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) return fmt.Errorf("%w: roster players must be unique", ErrInvalid)
} }
seen[slot.PlayerID] = true seen[slot.PlayerID] = true
counts[slot.Role]++
} }
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 { if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid) 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 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 { func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
confirmed := r.Confirmed confirmed := r.Confirmed
r.Confirmed = false r.Confirmed = false

View File

@@ -22,8 +22,9 @@ type ID string
type GlobalRole string type GlobalRole string
const ( const (
RolePlayer GlobalRole = "player" RolePlayer GlobalRole = "player"
RoleAdmin GlobalRole = "admin" RoleModerator GlobalRole = "moderator"
RoleAdmin GlobalRole = "admin"
) )
type Account struct { type Account struct {
@@ -36,6 +37,7 @@ type Account struct {
} }
func (a Account) IsAdmin() bool { return a.Role == RoleAdmin } func (a Account) IsAdmin() bool { return a.Role == RoleAdmin }
func (a Account) IsStaff() bool { return a.Role == RoleAdmin || a.Role == RoleModerator }
type Ratings struct { type Ratings struct {
Tank int `json:"tank"` Tank int `json:"tank"`

View File

@@ -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) { func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) {
roster := testRoster() roster := testRoster()
if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) { 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) { func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
rules := testRules() rules := testRules()
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules) series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)

View File

@@ -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'));

View File

@@ -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 { import {
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound, Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
@@ -11,7 +11,7 @@ import {
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent, 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' } from './api/client'
import { import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile, demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
@@ -22,6 +22,7 @@ import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks' import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
type RouterContext = { session: Session | null } 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 queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } })
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet }) const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet })
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage }) 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 bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage })
const adminRoute = createRoute({ const adminRoute = createRoute({
getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage, 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([ const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute, indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
@@ -85,6 +86,9 @@ function AppShell() {
} else if (message.topic === 'players') { } else if (message.topic === 'players') {
void client.invalidateQueries({ queryKey: ['players'] }) void client.invalidateQueries({ queryKey: ['players'] })
void client.invalidateQueries({ queryKey: ['profile'] }) void client.invalidateQueries({ queryKey: ['profile'] })
} else if (message.topic === 'accounts') {
void client.invalidateQueries({ queryKey: ['session'] })
void client.invalidateQueries({ queryKey: ['accounts'] })
} }
}, setRealtimeStatus), [client]) }, setRealtimeStatus), [client])
const logout = useMutation({ const logout = useMutation({
@@ -95,8 +99,8 @@ function AppShell() {
}, },
}) })
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM' const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
const navItems = session.data?.account.role === 'admin' const navItems = isStaff(session.data?.account.role)
? [...mainNavItems, { to: '/admin' as const, label: 'Admin', icon: Shield }] ? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
: mainNavItems : mainNavItems
return <div className="app-shell"> return <div className="app-shell">
<header className="topbar"> <header className="topbar">
@@ -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 <span className={`badge badge-${tone}`}>{children}</span> } function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> } function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> } function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
function EventsPage() { function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming') const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
@@ -132,14 +136,26 @@ function EventsPage() {
if (events.isError) return <ErrorState retry={() => void events.refetch()} /> if (events.isError) return <ErrorState retry={() => void events.refetch()} />
if (!events.data) return <LoadingState label="Loading events…" /> if (!events.data) return <LoadingState label="Loading events…" />
const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data
const liveEvent = events.data.find((event) => event.workflowState === 'Live')
return <div className="page"> return <div className="page">
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} /> <PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>} {liveEvent && <FeaturedLiveEvent event={liveEvent} />}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div> <div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>} {visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div> </div>
} }
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 <section className={`featured-event ${match ? '' : 'without-score'}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></div>{match && <div className="featured-score"><span>{match.teamAlpha.name}</span><strong>{match.score.alpha}<i>:</i>{match.score.beta}</strong><span>{match.teamBeta.name}</span></div>}{event.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Watch live <ChevronRight /></Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId: event.id }}>Open bracket <ChevronRight /></Link>}</section>
}
function EventCard({ event }: { event: MixEvent }) { function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt) const date = new Date(event.startsAt)
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge> const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
@@ -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)) const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout"> return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section> <section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside> <aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div> </div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
} }
@@ -233,17 +249,32 @@ function ProfilePage() {
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div> return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
} }
type AdminTab = 'events' | 'participants' | 'balance' type AdminTab = 'events' | 'participants' | 'balance' | 'moderators'
function AdminPage() { function AdminPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const [tab, setTab] = useState<AdminTab>('events') const [tab, setTab] = useState<AdminTab>('events')
const [createMode, setCreateMode] = useState(false) const [createMode, setCreateMode] = useState(false)
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined }) const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
const [selectedId, setSelectedId] = useState<string>() const [selectedId, setSelectedId] = useState<string>()
const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0] 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]) useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading) return <LoadingState label="Loading admin control room…" /> if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} /> if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div> const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const] : [])]
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={tab !== 'moderators' ? <>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events' && item !== 'moderators'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}</div>
}
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<RawAccount[]>(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)),
onError: () => void accounts.refetch(),
})
if (accounts.isLoading) return <LoadingState label="Loading accounts…" />
if (accounts.isError || !accounts.data) return <ErrorState message={accounts.error?.message} retry={() => void accounts.refetch()} />
return <section className="card moderator-panel"><div className="section-title"><div><h2>Moderators</h2><p>Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.</p></div><Badge tone="blue">{accounts.data.filter((account) => account.role === 'moderator').length} active</Badge></div><div className="moderator-list">{accounts.data.map((account) => <div key={account.id}><span className="mini-avatar">{account.username.slice(0, 2).toUpperCase()}</span><span><strong>{account.username}</strong><small>Discord ID · {account.discordId}</small></span>{account.role === 'admin' ? <Badge tone="warning">Administrator</Badge> : <label className="moderator-toggle"><input type="checkbox" checked={account.role === 'moderator'} disabled={update.isPending} onChange={(event) => update.mutate({ accountId: account.id, moderator: event.target.checked })} /><span>{account.role === 'moderator' ? 'Moderator' : 'Player'}</span></label>}</div>)}</div>{update.isError && <p className="error-note">{update.error.message}</p>}</section>
} }
function toLocalInput(value: string) { function toLocalInput(value: string) {
@@ -253,7 +284,7 @@ function toLocalInput(value: string) {
function eventInput(event?: MixEvent): EventInput { function eventInput(event?: MixEvent): EventInput {
const start = new Date(Date.now() + 86_400_000) 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) const deadline = new Date(start.getTime() - 3_600_000)
return { return {
name: event?.title ?? '', 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 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 <div className="admin-grid"> return <div className="admin-grid">
<form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}> <form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div> <div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div> <div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => setStart(e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div>
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label> <label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div> <div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
</form> </form>
@@ -423,15 +463,27 @@ function WorkflowControl({ eventId }: { eventId: string }) {
mutationFn: () => api.startScrim(eventId, event.data!.version), mutationFn: () => api.startScrim(eventId, event.data!.version),
onSuccess: (result) => { onSuccess: (result) => {
refresh() refresh()
if (result.series) void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } }) if (result.series) {
client.setQueryData(['series', result.series.id], result.series)
void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
}
else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } }) else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
}, },
onError: refresh, onError: refresh,
}) })
const back = useMutation({
mutationFn: () => api.revertWorkflowStage(eventId, event.data!.version),
onSuccess: (updated) => {
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
refresh()
},
onError: refresh,
})
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" /> if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} /> if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
const state = event.data.workflowState const state = event.data.workflowState
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error ?? back.error
return <div className="workflow-control"> return <div className="workflow-control">
<section className="card workflow-progress"> <section className="card workflow-progress">
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => { {(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
@@ -439,11 +491,12 @@ function WorkflowControl({ eventId }: { eventId: string }) {
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div> return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
})} })}
</section> </section>
{state !== 'RegistrationOpen' && state !== 'Live' && state !== 'Completed' && state !== 'Cancelled' && <div className="workflow-back"><button className="button secondary" disabled={back.isPending} onClick={() => back.mutate()}>{back.isPending ? 'Going back…' : '← Back'}</button><small>Return to the previous setup stage</small></div>}
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />} {state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />} {state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />} {state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>} {state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{roster.data && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />} {roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />} {state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />} {state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>} {state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
@@ -456,22 +509,65 @@ function WorkflowAction({ title, description, button, pending, onClick }: { titl
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section> return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
} }
type RosterDrag = { kind: 'team'; teamId: string; playerId: string; role: 'Tank' | 'Damage' | 'Support' } | { kind: 'reserve'; playerId: string }
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) { function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
const language = useLanguage() const language = useLanguage()
const client = useQueryClient()
const names = new Map(players.map((player) => [player.id, player.displayName])) const names = new Map(players.map((player) => [player.id, player.displayName]))
const mutate = useMutation({ const mutate = useMutation({
mutationFn: (action: { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string } | { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string } | { kind: 'captain'; teamId: string; playerId: string }) => { mutationFn: (action:
| { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string }
| { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string }
| { kind: 'move'; fromTeamId: string; playerId: string; toTeamId: string; role: 'Tank' | 'Damage' | 'Support' }
| { kind: 'place'; teamId: string; reservePlayerId: string; role: 'Tank' | 'Damage' | 'Support' }
| { kind: 'remove'; teamId: string; playerId: string }
| { kind: 'captain'; teamId: string; playerId: string }) => {
if (action.kind === 'swap') return api.swapRoster(eventId, { ...action, expectedVersion: roster.version }) if (action.kind === 'swap') return api.swapRoster(eventId, { ...action, expectedVersion: roster.version })
if (action.kind === 'substitute') return api.substituteRoster(eventId, { ...action, expectedVersion: roster.version }) if (action.kind === 'substitute') return api.substituteRoster(eventId, { ...action, expectedVersion: roster.version })
if (action.kind === 'move') return api.moveRosterPlayer(eventId, { ...action, expectedVersion: roster.version })
if (action.kind === 'place') return api.placeReservePlayer(eventId, { ...action, expectedVersion: roster.version })
if (action.kind === 'remove') return api.removeRosterPlayer(eventId, { ...action, expectedVersion: roster.version })
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version) return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
}, },
onSuccess: onChanged, onSuccess: (updated) => {
client.setQueryData(['roster', eventId], updated)
onChanged()
},
onError: onChanged, onError: onChanged,
}) })
return <section className="card roster-editor"><div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · swaps are restricted to the same role.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot) => { const startDrag = (event: DragEvent, payload: RosterDrag) => {
const swapOptions = roster.teams.flatMap((other) => other.id === team.id ? [] : other.slots.filter((candidate) => candidate.role === slot.role).map((candidate) => ({ ...candidate, teamId: other.id }))) event.dataTransfer.effectAllowed = 'move'
return <div className="roster-slot" key={slot.playerId}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <><select aria-label={`Swap ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => { const [teamBId, playerBId] = event.target.value.split(':'); if (playerBId) mutate.mutate({ kind: 'swap', teamAId: team.id, playerAId: slot.playerId, teamBId, playerBId }) }}><option value="">Swap</option>{swapOptions.map((candidate) => <option key={candidate.playerId} value={`${candidate.teamId}:${candidate.playerId}`}>{names.get(candidate.playerId)}</option>)}</select>{roster.reserve.length > 0 && <select aria-label={`Substitute ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => event.target.value && mutate.mutate({ kind: 'substitute', teamId: team.id, outgoingPlayerId: slot.playerId, reservePlayerId: event.target.value })}><option value="">Reserve</option>{roster.reserve.map((id) => <option value={id} key={id}>{names.get(id) ?? id}</option>)}</select>}</>}</div> event.dataTransfer.setData('application/x-mixmaker-roster', JSON.stringify(payload))
})}</article>)}</div>{roster.reserve.length > 0 && <div className="reserve-bar"><strong>Reserve</strong>{roster.reserve.map((id) => <Badge key={id}>{names.get(id) ?? id}</Badge>)}</div>}{mutate.isError && <p className="error-note">{mutate.error.message}</p>}</section> }
const readDrag = (event: DragEvent): RosterDrag | undefined => {
try {
return JSON.parse(event.dataTransfer.getData('application/x-mixmaker-roster')) as RosterDrag
} catch {
return undefined
}
}
const dropOnSlot = (event: DragEvent, teamId: string, slot: RawRosterDraft['teams'][number]['slots'][number]) => {
event.preventDefault()
const source = readDrag(event)
if (!source || locked || mutate.isPending) return
if (source.kind === 'reserve') {
mutate.mutate(slot.playerId
? { kind: 'substitute', teamId, outgoingPlayerId: slot.playerId, reservePlayerId: source.playerId }
: { kind: 'place', teamId, reservePlayerId: source.playerId, role: slot.role })
} else if (source.playerId !== slot.playerId) {
mutate.mutate(slot.playerId
? { kind: 'swap', teamAId: source.teamId, playerAId: source.playerId, teamBId: teamId, playerBId: slot.playerId }
: { kind: 'move', fromTeamId: source.teamId, playerId: source.playerId, toTeamId: teamId, role: slot.role })
}
}
return <section className="card roster-editor">
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players between equal-role slots or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
</div>
{mutate.isError && <p className="error-note">{mutate.error.message}</p>}
</section>
} }
function LegacyBalanceView({ eventId }: { eventId: string }) { function LegacyBalanceView({ eventId }: { eventId: string }) {
@@ -514,7 +610,7 @@ function LiveSeriesPage() {
mutationFn: () => { mutationFn: () => {
const data = series.data! const data = series.data!
if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version) if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
const actingTeamId = session.data?.account.role === 'admin' const actingTeamId = isStaff(session.data?.account.role)
? data.teamAlpha.id ? data.teamAlpha.id
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id : data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : '' : data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
@@ -542,7 +638,7 @@ function LiveSeriesPage() {
onError: () => void series.refetch(), onError: () => void series.refetch(),
}) })
if (series.isLoading || session.isLoading) return <LoadingState /> if (series.isLoading || session.isLoading) return <LoadingState />
if (series.isError || session.isError) return <ErrorState retry={() => { void series.refetch(); void session.refetch() }} /> if ((series.isError && !series.data) || (session.isError && !session.data)) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
if (!series.data || !session.data) return <LoadingState /> if (!series.data || !session.data) return <LoadingState />
const data = series.data const data = series.data
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div> return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
@@ -560,7 +656,7 @@ function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams() const { seriesId } = spectatorRoute.useParams()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined }) const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
if (series.isLoading) return <LoadingState /> if (series.isLoading) return <LoadingState />
if (series.isError || !series.data) return <ErrorState retry={() => void series.refetch()} /> if (!series.data) return <ErrorState message={series.error?.message} retry={() => void series.refetch()} />
const data = series.data const data = series.data
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div> return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
} }

View File

@@ -51,6 +51,7 @@ describe('backend DTO adapters', () => {
expect(adaptEvent({ ...event, state: 'Cancelled' }).status).toBe('cancelled') expect(adaptEvent({ ...event, state: 'Cancelled' }).status).toBe('cancelled')
expect(adaptEvent({ ...event, state: 'Completed' }).status).toBe('completed') expect(adaptEvent({ ...event, state: 'Completed' }).status).toBe('completed')
expect(adaptEvent({ ...event, state: 'Live' }).status).toBe('live') expect(adaptEvent({ ...event, state: 'Live' }).status).toBe('live')
expect(adaptEvent({ ...event, state: 'RegistrationClosed', endsAt: '2020-01-01T00:00:00Z' }).status).toBe('balancing')
}) })
it('keeps corrected results out of the authoritative score', () => { it('keeps corrected results out of the authoritative score', () => {
@@ -106,4 +107,17 @@ describe('actual backend routes', () => {
const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1') const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1')
expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' })) expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' }))
}) })
it('assigns moderators through the administrator-only route', async () => {
const account = { id: 'account-2', discordId: 'discord-2', username: 'Mod', avatarUrl: '', role: 'moderator', createdAt: '2026-07-01T00:00:00Z' }
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(account), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.setModerator(account.id, true)
expect(fetchMock).toHaveBeenCalledWith('/api/accounts/account-2/moderator', expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ moderator: true }),
}))
})
}) })

View File

@@ -1,6 +1,6 @@
import { toRankOrdinal, type RankOrdinal } from '../ranks' import { toRankOrdinal, type RankOrdinal } from '../ranks'
export type AccountRole = 'admin' | 'player' export type AccountRole = 'admin' | 'moderator' | 'player'
export type RsvpStatus = 'going' | 'maybe' | 'not_going' export type RsvpStatus = 'going' | 'maybe' | 'not_going'
export type PlayerRole = 'tank' | 'damage' | 'support' export type PlayerRole = 'tank' | 'damage' | 'support'
export type TeamSide = 'alpha' | 'beta' export type TeamSide = 'alpha' | 'beta'
@@ -216,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
} }
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent { export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now() const now = Date.now()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime() const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 } const counts = { going: 0, maybe: 0, not_going: 0 }
rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ }) rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ })
@@ -229,7 +228,7 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
endsAt: raw.endsAt, endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline, registrationDeadline: raw.registrationDeadline,
status: raw.state === 'Cancelled' ? 'cancelled' status: raw.state === 'Cancelled' ? 'cancelled'
: raw.state === 'Completed' || now > ends ? 'completed' : raw.state === 'Completed' ? 'completed'
: raw.state === 'Live' ? 'live' : raw.state === 'Live' ? 'live'
: raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing', : raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing',
rulesetName: 'Server ruleset', rulesetName: 'Server ruleset',
@@ -474,6 +473,9 @@ export const api = {
}), }),
})), })),
players: async () => (await raw.players()).map(adaptPlayer), players: async () => (await raw.players()).map(adaptPlayer),
accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),
events: async () => { events: async () => {
const [events, session] = await Promise.all([raw.events(), raw.session()]) const [events, session] = await Promise.all([raw.events(), raw.session()])
return Promise.all(events.map(async (event) => { return Promise.all(events.map(async (event) => {
@@ -535,6 +537,11 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ rulesetId, expectedVersion }), body: JSON.stringify({ rulesetId, expectedVersion }),
})), })),
revertWorkflowStage: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/workflow/back`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
})),
generateWorkflowBalance: async (eventId: string, expectedVersion: number) => { generateWorkflowBalance: async (eventId: string, expectedVersion: number) => {
const [result, players] = await Promise.all([ const [result, players] = await Promise.all([
request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, { request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, {
@@ -557,6 +564,12 @@ export const api = {
}), }),
swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) => swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }), request<RawRosterDraft>(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }),
moveRosterPlayer: (eventId: string, input: { fromTeamId: string; playerId: string; toTeamId: string; role: RawSlot['role']; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/move`, { method: 'POST', body: JSON.stringify(input) }),
placeReservePlayer: (eventId: string, input: { teamId: string; reservePlayerId: string; role: RawSlot['role']; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/place-reserve`, { method: 'POST', body: JSON.stringify(input) }),
removeRosterPlayer: (eventId: string, input: { teamId: string; playerId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/remove`, { method: 'POST', body: JSON.stringify(input) }),
substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) => substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) =>
request<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }), request<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) => setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
@@ -569,11 +582,13 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }), body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}), }),
startScrim: (eventId: string, expectedVersion: number) => startScrim: async (eventId: string, expectedVersion: number) => {
request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, { const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ expectedVersion }), body: JSON.stringify({ expectedVersion }),
}), })
return { ...result, series: result.series ? await adaptSeriesWithTeams(result.series) : undefined }
},
selectBalance: (eventId: string, candidate: BalanceCandidate) => selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, { request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT', method: 'PUT',

View File

@@ -6,6 +6,18 @@ const translations: Record<string, string> = {
'Live': 'Эфир', 'Live': 'Эфир',
'Bracket': 'Сетка', 'Bracket': 'Сетка',
'Admin': 'Админ', 'Admin': 'Админ',
'Moderation': 'Модерация',
'Moderators': 'Модераторы',
'Moderator': 'Модератор',
'Player': 'Игрок',
'Administrator': 'Администратор',
'Moderator access': 'Доступ модераторов',
'Grant moderation access to trusted Discord users.': 'Назначайте модераторами доверенных пользователей Discord.',
'Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.': 'Модераторы могут управлять событиями, участниками, составами и скримами. Изменять этот список могут только администраторы.',
'Only administrators can change this list.': 'Только администраторы могут изменять этот список.',
'← Back': '← Назад',
'Going back…': 'Возвращаемся…',
'Return to the previous setup stage': 'Вернуться к предыдущему этапу настройки',
'Profile': 'Профиль', 'Profile': 'Профиль',
'FAQ': 'FAQ', 'FAQ': 'FAQ',
'Language': 'Язык', 'Language': 'Язык',
@@ -38,6 +50,7 @@ const translations: Record<string, string> = {
'Upcoming': 'Предстоящие', 'Upcoming': 'Предстоящие',
'All': 'Все', 'All': 'Все',
'Live now': 'Сейчас в эфире', 'Live now': 'Сейчас в эфире',
'The scrim has started': 'Скрим начался',
'Watch live': 'Смотреть', 'Watch live': 'Смотреть',
'Coming up': 'Скоро', 'Coming up': 'Скоро',
'Filter events': 'Фильтровать события', 'Filter events': 'Фильтровать события',

View File

@@ -74,6 +74,7 @@ main { min-height: calc(100vh - 72px); }
.segmented button.active { color: #fff; background: var(--surface-3); } .segmented button.active { color: #fff; background: var(--surface-3); }
.featured-event { min-height: 180px; padding: 28px 32px; display: grid; grid-template-columns: 1.5fr 1fr auto; align-items: center; gap: 28px; border: 1px solid #46301f; border-radius: 14px; background: linear-gradient(115deg, rgba(53,28,13,.92), rgba(18,20,25,.96) 52%, rgba(11,31,45,.75)), repeating-linear-gradient(135deg,transparent 0 20px,rgba(255,255,255,.015) 20px 21px); overflow: hidden; position: relative; } .featured-event { min-height: 180px; padding: 28px 32px; display: grid; grid-template-columns: 1.5fr 1fr auto; align-items: center; gap: 28px; border: 1px solid #46301f; border-radius: 14px; background: linear-gradient(115deg, rgba(53,28,13,.92), rgba(18,20,25,.96) 52%, rgba(11,31,45,.75)), repeating-linear-gradient(135deg,transparent 0 20px,rgba(255,255,255,.015) 20px 21px); overflow: hidden; position: relative; }
.featured-event.without-score { grid-template-columns: 1fr auto; }
.featured-event::after { content: ""; width: 240px; height: 240px; border-radius: 50%; border: 45px solid rgba(255,122,26,.06); position: absolute; right: 15%; top: -70px; pointer-events: none; } .featured-event::after { content: ""; width: 240px; height: 240px; border-radius: 50%; border: 45px solid rgba(255,122,26,.06); position: absolute; right: 15%; top: -70px; pointer-events: none; }
.featured-event h2 { font-size: 26px; margin: 16px 0 6px; } .featured-event h2 { font-size: 26px; margin: 16px 0 6px; }
.featured-event p { color: var(--muted); font-size: 13px; } .featured-event p { color: var(--muted); font-size: 13px; }
@@ -234,14 +235,21 @@ textarea { min-height: 85px; resize: vertical; }
.workflow-progress div { min-width: 105px; display: flex; align-items: center; gap: 7px; color: #555e69; font-size: 9px; text-transform: uppercase; } .workflow-progress div { min-width: 105px; display: flex; align-items: center; gap: 7px; color: #555e69; font-size: 9px; text-transform: uppercase; }
.workflow-progress span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; } .workflow-progress span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; }
.workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; } .workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; }
.workflow-back { display: flex; align-items: center; gap: 10px; margin: -5px 0 0; }.workflow-back small { color: var(--muted); }
.workflow-action { min-height: 105px; padding: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } .workflow-action { min-height: 105px; padding: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.workflow-action h2 { font-size: 18px; }.workflow-action p { margin-top: 5px; color: var(--muted); font-size: 11px; } .workflow-action h2 { font-size: 18px; }.workflow-action p { margin-top: 5px; color: var(--muted); font-size: 11px; }
.roster-editor { padding: 22px; }.roster-editor > .section-title { margin-top: 0; } .roster-editor { padding: 22px; }.roster-editor > .section-title { margin-top: 0; }
.roster-editor-body { display: grid; grid-template-columns: minmax(0,1fr) 220px; gap: 14px; align-items: stretch; }
.workflow-team { border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #101319; } .workflow-team { border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #101319; }
.workflow-team header { padding: 14px; display: flex; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); } .workflow-team header { padding: 14px; display: flex; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); }
.workflow-team header label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 9px; }.workflow-team header select { padding: 7px; } .workflow-team header label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 9px; }.workflow-team header select { padding: 7px; }
.roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto minmax(105px,auto) minmax(105px,auto); align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; } .roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto 30px; align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; transition: background .15s,border-color .15s; }
.roster-slot > small { color: var(--muted); }.roster-slot select { max-width: 130px; padding: 7px; font-size: 9px; } .roster-slot > small { color: var(--muted); }.roster-slot > strong[draggable="true"] { cursor: grab; }.roster-slot > strong[draggable="true"]:active { cursor: grabbing; }
.roster-slot.empty { border: 1px dashed #424a55; background: #15181d; }.roster-slot.empty > strong { color: #737b86; font-style: italic; }.slot-remove { width: 28px; height: 28px; }.slot-remove svg { width: 13px; }
.roster-reserve { min-height: 100%; padding: 14px; border: 1px dashed #3b424c; border-radius: 9px; background: #0d1015; }
.roster-reserve > div:first-child { display: flex; gap: 9px; align-items: center; margin-bottom: 13px; }.roster-reserve > div:first-child svg { width: 17px; color: var(--orange); }.roster-reserve span { display: grid; }.roster-reserve small,.reserve-list p { color: var(--muted); font-size: 9px; }
.reserve-list { display: grid; gap: 7px; }.reserve-list button { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); cursor: grab; text-align: left; }.reserve-list button:active { cursor: grabbing; }.mini-avatar { width: 25px; height: 25px; display: grid !important; place-items: center; border-radius: 5px; background: #253728; color: var(--green); font-size: 8px; }
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; } .scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); } .score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
@@ -301,7 +309,7 @@ textarea { min-height: 85px; resize: vertical; }
.teammate-grid { grid-template-columns: repeat(2,1fr); } .teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; } .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; } .candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-slot { grid-template-columns: 24px 1fr auto; }.roster-slot select { grid-column: 2/-1; max-width: none; width: 100%; } .workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-editor-body { grid-template-columns: 1fr; }.roster-slot { grid-template-columns: 24px 1fr auto 30px; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; } .scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; } .turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; } .result-form { grid-template-columns: 1fr; }

View File

@@ -5,6 +5,7 @@
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → атомарный старт Bo3 или турнира → жеребьёвка → баны карт → баны героев → результаты и завершение. Состояние события, roster draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией без ручного обновления страницы. Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → атомарный старт Bo3 или турнира → жеребьёвка → баны карт → баны героев → результаты и завершение. Состояние события, roster draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией без ручного обновления страницы.
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; demo fixtures остались только локальным showcase. Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; demo fixtures остались только локальным showcase.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
## Подтверждённые требования ## Подтверждённые требования

View File

@@ -66,7 +66,7 @@ frontend/src/
- Players: профили, рейтинги, предпочтения ролей, желаемых союзников и avoid-списки; - Players: профили, рейтинги, предпочтения ролей, желаемых союзников и avoid-списки;
- Auth: Discord OAuth, сессии и роли доступа; - Auth: Discord OAuth, сессии и роли доступа;
- Admin: управление игроками, регистрациями, событиями и капитанами; - Staff (Admin/Moderator): управление игроками, регистрациями, событиями и капитанами;
- Events: календарь, регистрации участников и состояние игрового вечера; - Events: календарь, регистрации участников и состояние игрового вечера;
- Balancing: генерация и оценка составов; - Balancing: генерация и оценка составов;
- Draft: жеребьёвка, карты и герои; - Draft: жеребьёвка, карты и герои;
@@ -102,7 +102,7 @@ Application-слой управляет единым versioned workflow собы
Вход выполняется через Discord OAuth 2.0. После callback backend создаёт собственную серверную сессию и безопасную HttpOnly-cookie; OAuth-токен Discord не передаётся frontend. Discord ID является внешним идентификатором аккаунта. Вход выполняется через Discord OAuth 2.0. После callback backend создаёт собственную серверную сессию и безопасную HttpOnly-cookie; OAuth-токен Discord не передаётся frontend. Discord ID является внешним идентификатором аккаунта.
Глобальные роли MVP — Admin и Player; Captain назначается Admin для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin управляет событиями, может изменить RSVP за игрока, подтверждает участников, назначает капитанов и фиксирует результаты. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс. Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс.
### Календарь и участие ### Календарь и участие

View File

@@ -10,7 +10,7 @@
### Account ### Account
Учётная запись Discord с глобальной ролью Player или Admin. Admin назначается только другим Admin либо через начальный список Discord ID в конфигурации развёртывания. Изменение роли записывается в аудит. Учётная запись Discord с глобальной ролью Player, Moderator или Admin. Moderator имеет те же операционные права, что Admin, но не может назначать и снимать модераторов. Moderator назначается только Admin; начальные Admin задаются списком Discord ID в конфигурации развёртывания. Изменение роли записывается в аудит.
### Event ### Event
@@ -33,7 +33,7 @@
7. `Completed` — серия или турнир завершены; 7. `Completed` — серия или турнир завершены;
8. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы. 8. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна только Admin, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной Admin-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит. Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
### Team ### Team

View File

@@ -110,6 +110,40 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/Player" $ref: "#/components/schemas/Player"
/api/accounts:
get:
tags: [Admin]
operationId: listAccounts
responses:
"200":
description: Accounts visible to administrators
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Account" }
"403": { $ref: "#/components/responses/Forbidden" }
/api/accounts/{accountId}/moderator:
patch:
tags: [Admin]
operationId: setModerator
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [moderator]
properties:
moderator: { type: boolean }
responses:
"200": { description: Updated account, content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } } }
"403": { $ref: "#/components/responses/Forbidden" }
/api/events: /api/events:
get: get:
tags: [Events] tags: [Events]
@@ -328,6 +362,17 @@ paths:
responses: responses:
"200": { description: Registration closed, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } } "200": { description: Registration closed, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
"409": { $ref: "#/components/responses/Conflict" } "409": { $ref: "#/components/responses/Conflict" }
/api/events/{eventId}/workflow/back:
post:
tags: [Events]
operationId: revertEventWorkflowStage
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody:
required: true
content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } }
responses:
"200": { description: Workflow moved back one stage, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
"409": { $ref: "#/components/responses/Conflict" }
/api/events/{eventId}/balance/generate: /api/events/{eventId}/balance/generate:
post: post:
tags: [Teams] tags: [Teams]
@@ -383,6 +428,36 @@ paths:
responses: responses:
"200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } } "200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
"409": { $ref: "#/components/responses/Conflict" } "409": { $ref: "#/components/responses/Conflict" }
/api/events/{eventId}/roster/move:
post:
tags: [Teams]
operationId: moveRosterPlayer
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody:
required: true
content: { application/json: { schema: { $ref: "#/components/schemas/RosterMoveCommand" } } }
responses:
"200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
/api/events/{eventId}/roster/place-reserve:
post:
tags: [Teams]
operationId: placeReserveRosterPlayer
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody:
required: true
content: { application/json: { schema: { $ref: "#/components/schemas/RosterPlaceReserveCommand" } } }
responses:
"200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
/api/events/{eventId}/roster/remove:
post:
tags: [Teams]
operationId: removeRosterPlayer
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody:
required: true
content: { application/json: { schema: { $ref: "#/components/schemas/RosterRemoveCommand" } } }
responses:
"200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
/api/events/{eventId}/roster/substitute: /api/events/{eventId}/roster/substitute:
post: post:
tags: [Teams] tags: [Teams]
@@ -788,7 +863,7 @@ components:
format: uri format: uri
role: role:
type: string type: string
enum: [player, admin] enum: [player, moderator, admin]
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
@@ -1013,6 +1088,30 @@ components:
teamBId: { type: string } teamBId: { type: string }
playerBId: { type: string } playerBId: { type: string }
expectedVersion: { type: integer } expectedVersion: { type: integer }
RosterMoveCommand:
type: object
required: [fromTeamId, playerId, toTeamId, role, expectedVersion]
properties:
fromTeamId: { type: string }
playerId: { type: string }
toTeamId: { type: string }
role: { type: string, enum: [Tank, Damage, Support] }
expectedVersion: { type: integer }
RosterPlaceReserveCommand:
type: object
required: [teamId, reservePlayerId, role, expectedVersion]
properties:
teamId: { type: string }
reservePlayerId: { type: string }
role: { type: string, enum: [Tank, Damage, Support] }
expectedVersion: { type: integer }
RosterRemoveCommand:
type: object
required: [teamId, playerId, expectedVersion]
properties:
teamId: { type: string }
playerId: { type: string }
expectedVersion: { type: integer }
RosterSubstitutionCommand: RosterSubstitutionCommand:
type: object type: object
required: [teamId, outgoingPlayerId, reservePlayerId, expectedVersion] required: [teamId, outgoingPlayerId, reservePlayerId, expectedVersion]