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.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players)
api.Get("/api/accounts", s.accounts)
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
api.Get("/api/events", s.events)
api.Post("/api/events", s.createEvent)
api.Get("/api/events/{eventID}", s.getEvent)
@@ -84,10 +86,14 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
api.Get("/api/events/{eventID}/roster", s.getRoster)
api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance)
api.Post("/api/events/{eventID}/roster/swap", s.swapRoster)
api.Post("/api/events/{eventID}/roster/move", s.moveRosterPlayer)
api.Post("/api/events/{eventID}/roster/place-reserve", s.placeReservePlayer)
api.Post("/api/events/{eventID}/roster/remove", s.removeRosterPlayer)
api.Post("/api/events/{eventID}/roster/substitute", s.substituteRoster)
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
api.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage)
api.Post("/api/events/{eventID}/start", s.startScrim)
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
api.Post("/api/rulesets", s.saveRuleset)
@@ -220,8 +226,8 @@ func (s *Server) authenticate(next http.Handler) http.Handler {
}
func who(r *http.Request) identity { return r.Context().Value(identityKey{}).(identity) }
func requireAdmin(r *http.Request) error {
if !who(r).account.IsAdmin() {
func requireStaff(r *http.Request) error {
if !who(r).account.IsStaff() {
return domain.ErrForbidden
}
return nil
@@ -253,6 +259,22 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200)
}
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
out, err := s.service.ListAccounts(r.Context(), who(r).account)
respond(w, out, err, http.StatusOK)
}
func (s *Server) setModerator(w http.ResponseWriter, r *http.Request) {
var in struct {
Moderator bool `json:"moderator"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SetModerator(r.Context(), who(r).account, chi.URLParam(r, "accountID"), in.Moderator)
respond(w, out, err, http.StatusOK)
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
from := time.Unix(0, 0).UTC()
if raw := r.URL.Query().Get("from"); raw != "" {
@@ -358,7 +380,7 @@ func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
@@ -410,7 +432,7 @@ func (s *Server) coinToss(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createMapDraft(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
@@ -478,7 +500,7 @@ func (s *Server) mapBan(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createHeroDraft(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}
@@ -532,7 +554,7 @@ func (s *Server) heroBan(w http.ResponseWriter, r *http.Request) {
func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
id := who(r)
if id.account.IsAdmin() {
if id.account.IsStaff() {
return nil
}
teams, err := s.store.ListTeams(r.Context(), eventID)
@@ -548,7 +570,7 @@ func (s *Server) authorizeTeam(r *http.Request, eventID, teamID string) error {
}
func (s *Server) createTournament(w http.ResponseWriter, r *http.Request) {
if err := requireAdmin(r); err != nil {
if err := requireStaff(r); err != nil {
writeError(w, err)
return
}

View File

@@ -79,6 +79,48 @@ func (s *Server) swapRoster(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, http.StatusOK)
}
func (s *Server) moveRosterPlayer(w http.ResponseWriter, r *http.Request) {
var in struct {
FromTeamID string `json:"fromTeamId"`
PlayerID string `json:"playerId"`
ToTeamID string `json:"toTeamId"`
Role domain.Role `json:"role"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.MoveRosterPlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.FromTeamID, in.PlayerID, in.ToTeamID, in.Role, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) placeReservePlayer(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
ReservePlayerID string `json:"reservePlayerId"`
Role domain.Role `json:"role"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.PlaceReservePlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.ReservePlayerID, in.Role, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) removeRosterPlayer(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
PlayerID string `json:"playerId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.RemoveRosterPlayer(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.PlayerID, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) substituteRoster(w http.ResponseWriter, r *http.Request) {
s.handleSubstitute(w, r, false)
}
@@ -126,6 +168,17 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, http.StatusOK)
}
func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.RevertWorkflowStage(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) startScrim(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`

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)
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(discord_id) DO UPDATE
SET username=excluded.username,avatar_url=excluded.avatar_url,
role=CASE WHEN accounts.role='admin' THEN accounts.role ELSE excluded.role END
role=CASE WHEN accounts.role IN ('admin','moderator') THEN accounts.role ELSE excluded.role END
RETURNING id,discord_id,username,avatar_url,role,created_at`,
account.ID, account.DiscordID, account.Username, account.AvatarURL, role, account.CreatedAt).
Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt)
@@ -84,6 +84,31 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
return account, p, tx.Commit(ctx)
}
func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) {
rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`)
if err != nil {
return nil, err
}
defer rows.Close()
accounts := make([]domain.Account, 0)
for rows.Next() {
var account domain.Account
if err := rows.Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt); err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, rows.Err()
}
func (s *Store) UpdateAccountRole(ctx context.Context, accountID string, role domain.GlobalRole) (domain.Account, error) {
var account domain.Account
err := s.pool.QueryRow(ctx, `UPDATE accounts SET role=$2 WHERE id=$1 AND role<>'admin'
RETURNING id,discord_id,username,avatar_url,role,created_at`, accountID, role).
Scan(&account.ID, &account.DiscordID, &account.Username, &account.AvatarURL, &account.Role, &account.CreatedAt)
return account, mapError(err)
}
func (s *Store) CreateSession(ctx context.Context, token, accountID string, expires time.Time) error {
_, err := s.pool.Exec(ctx, `INSERT INTO sessions(token_hash,account_id,expires_at) VALUES($1,$2,$3)`, HashToken(token), accountID, expires)
return err
@@ -331,6 +356,21 @@ func (s *Store) GetRoster(ctx context.Context, eventID string) (domain.RosterDra
return roster, mapError(err)
}
func (s *Store) ResetRoster(ctx context.Context, eventID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err = tx.Exec(ctx, `DELETE FROM event_rosters WHERE event_id=$1`, eventID); err != nil {
return err
}
if _, err = tx.Exec(ctx, `DELETE FROM teams WHERE event_id=$1`, eventID); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVersion int, series []domain.Series, tournament *domain.Tournament) error {
tx, err := s.pool.Begin(ctx)
if err != nil {

View File

@@ -15,6 +15,8 @@ type Store interface {
Ready(context.Context) error
UpsertDiscordAccount(context.Context, domain.Account, bool) (domain.Account, domain.Player, error)
AccountBySession(context.Context, string) (domain.Account, domain.Player, error)
ListAccounts(context.Context) ([]domain.Account, error)
UpdateAccountRole(context.Context, string, domain.GlobalRole) (domain.Account, error)
CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
@@ -27,6 +29,7 @@ type Store interface {
SaveEventWorkflow(context.Context, domain.Event, int) (domain.Event, error)
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
GetRoster(context.Context, string) (domain.RosterDraft, error)
ResetRoster(context.Context, string) error
StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
DeleteEvent(context.Context, string) error
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
@@ -73,6 +76,29 @@ func (s *Service) Authenticate(ctx context.Context, session string) (domain.Acco
return s.Store.AccountBySession(ctx, session)
}
func (s *Service) ListAccounts(ctx context.Context, actor domain.Account) ([]domain.Account, error) {
if !actor.IsAdmin() {
return nil, domain.ErrForbidden
}
return s.Store.ListAccounts(ctx)
}
func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accountID string, moderator bool) (domain.Account, error) {
if !actor.IsAdmin() {
return domain.Account{}, domain.ErrForbidden
}
role := domain.RolePlayer
if moderator {
role = domain.RoleModerator
}
account, err := s.Store.UpdateAccountRole(ctx, accountID, role)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "account.role_changed", accountID, map[string]domain.GlobalRole{"role": role})
s.Bus.Publish("accounts", account)
}
return account, err
}
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) {
if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden
@@ -117,7 +143,7 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
}
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
if event.RegistrationDeadline.IsZero() {
@@ -139,7 +165,7 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
}
func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID string, changes domain.Event) (domain.Event, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
current, err := s.Store.GetEvent(ctx, eventID)
@@ -172,7 +198,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
playerID = actorPlayer.ID
}
if playerID != actorPlayer.ID {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.RSVP{}, domain.ErrForbidden
}
source = domain.SourceAdmin
@@ -184,7 +210,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
if event.State != domain.RegistrationOpen {
return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict)
}
if !actor.IsAdmin() && s.Now().After(event.RegistrationDeadline) {
if !actor.IsStaff() && s.Now().After(event.RegistrationDeadline) {
return domain.RSVP{}, fmt.Errorf("%w: registration deadline has passed", domain.ErrConflict)
}
rsvp := domain.RSVP{EventID: eventID, PlayerID: playerID, ActorAccountID: actor.ID, Status: status, Source: source, UpdatedAt: s.Now()}
@@ -200,7 +226,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
}
func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -219,7 +245,7 @@ func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, e
}
func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Player{}, domain.RSVP{}, domain.ErrForbidden
}
displayName = strings.TrimSpace(displayName)
@@ -265,7 +291,7 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
}
func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return nil, domain.ErrForbidden
}
rsvps, err := s.Store.ListRSVPs(ctx, eventID)
@@ -290,7 +316,7 @@ func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID str
}
func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate) error {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.ErrForbidden
}
if err := s.Store.SaveTeams(ctx, eventID, candidate.Teams); err != nil {
@@ -302,7 +328,7 @@ func (s *Service) SelectBalance(ctx context.Context, actor domain.Account, event
}
func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamID, playerID string) (domain.Team, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Team{}, domain.ErrForbidden
}
team, err := s.Store.AssignCaptain(ctx, teamID, playerID)
@@ -314,11 +340,11 @@ func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamI
}
func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool {
return actor.IsAdmin() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID)
return actor.IsStaff() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID)
}
func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Series{}, domain.ErrForbidden
}
series, err := s.Store.GetSeries(ctx, seriesID)
@@ -340,7 +366,7 @@ func (s *Service) RecordMap(ctx context.Context, actor domain.Account, seriesID,
}
func (s *Service) CorrectMap(ctx context.Context, actor domain.Account, seriesID string, resultIndex int, mapName string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Series{}, domain.ErrForbidden
}
series, err := s.Store.GetSeries(ctx, seriesID)

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) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -44,7 +44,7 @@ func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID
}
func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID string) error {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.ErrForbidden
}
if err := s.Store.DeleteEvent(ctx, eventID); err != nil {
@@ -57,7 +57,7 @@ func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID
}
func (s *Service) CloseRegistration(ctx context.Context, actor domain.Account, eventID, rulesetID string, expectedVersion int) (domain.Event, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -81,7 +81,7 @@ func (s *Service) CloseRegistration(ctx context.Context, actor domain.Account, e
}
func (s *Service) GenerateBalance(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (BalanceWorkflow, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return BalanceWorkflow{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -106,7 +106,7 @@ func (s *Service) GenerateBalance(ctx context.Context, actor domain.Account, eve
}
func (s *Service) SelectWorkflowBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -137,7 +137,7 @@ func (s *Service) SelectWorkflowBalance(ctx context.Context, actor domain.Accoun
}
func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID, teamA, playerA, teamB, playerB string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
@@ -159,8 +159,83 @@ func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID,
return roster, err
}
func (s *Service) MoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, fromTeamID, playerID, toTeamID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
if err = roster.MoveToEmpty(fromTeamID, playerID, toTeamID, role); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_moved")
}
func (s *Service) PlaceReservePlayer(ctx context.Context, actor domain.Account, eventID, teamID, reserveID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return roster, err
}
rating := 0
for _, player := range players {
if player.ID == reserveID {
rating = ratingForRole(player, role)
break
}
}
if rating == 0 {
return roster, domain.ErrNotFound
}
if err = roster.PlaceReserve(teamID, role, reserveID, rating); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.reserve_placed")
}
func (s *Service) RemoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
if err = roster.MoveToReserve(teamID, playerID); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_removed")
}
func (s *Service) saveRosterChange(ctx context.Context, actor domain.Account, roster domain.RosterDraft, expectedVersion int, action string) (domain.RosterDraft, error) {
out, err := s.Store.SaveRoster(ctx, roster, expectedVersion)
if err == nil {
_ = s.Store.SaveTeams(ctx, roster.EventID, out.Teams)
_ = s.Store.AppendAudit(ctx, actor.ID, action, roster.EventID, out)
s.Bus.Publish("event:"+roster.EventID, out)
}
return out, err
}
func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, eventID, teamID, outgoingID, reserveID string, expectedVersion int, emergency bool) (domain.RosterDraft, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -220,7 +295,7 @@ func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, ev
}
func (s *Service) SetRosterCaptain(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
@@ -253,7 +328,7 @@ func (s *Service) SetRosterCaptain(ctx context.Context, actor domain.Account, ev
}
func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion, expectedRosterVersion int) (domain.Event, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
@@ -287,8 +362,59 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even
return event, err
}
func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return event, err
}
oldVersion := event.Version
var previous domain.EventState
switch event.State {
case domain.RegistrationClosed:
previous = domain.RegistrationOpen
case domain.Balancing:
previous = domain.RegistrationClosed
case domain.RostersDraft:
previous = domain.Balancing
case domain.RostersConfirmed:
previous = domain.RostersDraft
default:
return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict)
}
if err = event.Transition([]domain.EventState{event.State}, previous, expectedVersion); err != nil {
return event, err
}
if previous == domain.Balancing {
if err = s.Store.ResetRoster(ctx, eventID); err != nil {
return event, err
}
}
if previous == domain.RostersDraft {
roster, rosterErr := s.Store.GetRoster(ctx, eventID)
if rosterErr != nil {
return event, rosterErr
}
rosterVersion := roster.Version
roster.Confirmed = false
roster.Version++
if _, rosterErr = s.Store.SaveRoster(ctx, roster, rosterVersion); rosterErr != nil {
return event, rosterErr
}
}
event.UpdatedAt = s.Now()
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "workflow.reverted", eventID, map[string]domain.EventState{"state": previous})
s.Bus.Publish("event:"+eventID, event)
}
return event, err
}
func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (ScrimStart, error) {
if !actor.IsAdmin() {
if !actor.IsStaff() {
return ScrimStart{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)

View File

@@ -49,11 +49,17 @@ func (r RosterDraft) Validate(requireCaptains bool) error {
}
counts := map[Role]int{}
for _, slot := range team.Slots {
if slot.PlayerID == "" || seen[slot.PlayerID] {
counts[slot.Role]++
if slot.PlayerID == "" {
if requireCaptains {
return fmt.Errorf("%w: every roster slot must be filled", ErrInvalid)
}
continue
}
if seen[slot.PlayerID] {
return fmt.Errorf("%w: roster players must be unique", ErrInvalid)
}
seen[slot.PlayerID] = true
counts[slot.Role]++
}
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid)
@@ -127,6 +133,88 @@ func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating in
return ErrNotFound
}
func (r *RosterDraft) PlaceReserve(teamID string, role Role, reserveID string, rating int) error {
if r.Confirmed {
return fmt.Errorf("%w: rosters are locked", ErrConflict)
}
reserveIndex := slices.Index(r.Reserve, reserveID)
if reserveIndex < 0 {
return fmt.Errorf("%w: player is not in reserve", ErrInvalid)
}
for teamIndex := range r.Teams {
if r.Teams[teamIndex].ID != teamID {
continue
}
for slotIndex := range r.Teams[teamIndex].Slots {
slot := &r.Teams[teamIndex].Slots[slotIndex]
if slot.Role == role && slot.PlayerID == "" {
slot.PlayerID, slot.Rating = reserveID, rating
r.Reserve = slices.Delete(r.Reserve, reserveIndex, reserveIndex+1)
r.Version++
return r.Validate(false)
}
}
}
return fmt.Errorf("%w: empty role slot not found", ErrNotFound)
}
func (r *RosterDraft) MoveToEmpty(fromTeamID, playerID, toTeamID string, role Role) error {
if r.Confirmed {
return fmt.Errorf("%w: rosters are locked", ErrConflict)
}
var source, target *Slot
var sourceTeam *Team
for teamIndex := range r.Teams {
team := &r.Teams[teamIndex]
for slotIndex := range team.Slots {
slot := &team.Slots[slotIndex]
if team.ID == fromTeamID && slot.PlayerID == playerID {
source, sourceTeam = slot, team
}
if team.ID == toTeamID && slot.Role == role && slot.PlayerID == "" {
target = slot
}
}
}
if source == nil || target == nil {
return ErrNotFound
}
if source.Role != target.Role {
return fmt.Errorf("%w: only equal roles can be moved", ErrInvalid)
}
target.PlayerID, target.Rating = source.PlayerID, source.Rating
source.PlayerID, source.Rating = "", 0
if sourceTeam.CaptainPlayerID == playerID {
sourceTeam.CaptainPlayerID = ""
}
r.Version++
return r.Validate(false)
}
func (r *RosterDraft) MoveToReserve(teamID, playerID string) error {
if r.Confirmed {
return fmt.Errorf("%w: rosters are locked", ErrConflict)
}
for teamIndex := range r.Teams {
if r.Teams[teamIndex].ID != teamID {
continue
}
for slotIndex := range r.Teams[teamIndex].Slots {
slot := &r.Teams[teamIndex].Slots[slotIndex]
if slot.PlayerID == playerID {
slot.PlayerID, slot.Rating = "", 0
r.Reserve = append(r.Reserve, playerID)
if r.Teams[teamIndex].CaptainPlayerID == playerID {
r.Teams[teamIndex].CaptainPlayerID = ""
}
r.Version++
return r.Validate(false)
}
}
}
return ErrNotFound
}
func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
confirmed := r.Confirmed
r.Confirmed = false

View File

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

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) {
roster := testRoster()
if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) {
@@ -36,6 +46,35 @@ func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) {
}
}
func TestRosterDragOperationsPreserveEmptyRoleSlots(t *testing.T) {
roster := testRoster()
roster.Teams[0].CaptainPlayerID = "a-t"
if err := roster.MoveToReserve("a", "a-t"); err != nil {
t.Fatal(err)
}
if roster.Teams[0].Slots[0].PlayerID != "" || roster.Teams[0].Slots[0].Role != Tank {
t.Fatal("removed tank did not leave an identifiable empty tank slot")
}
if roster.Teams[0].CaptainPlayerID != "" {
t.Fatal("removed captain was retained")
}
if err := roster.Validate(true); !errors.Is(err, ErrInvalid) {
t.Fatalf("incomplete roster was confirmable: %v", err)
}
if err := roster.PlaceReserve("a", Tank, "reserve", 31); err != nil {
t.Fatal(err)
}
if err := roster.MoveToReserve("b", "b-t"); err != nil {
t.Fatal(err)
}
if err := roster.MoveToEmpty("a", "reserve", "b", Tank); err != nil {
t.Fatal(err)
}
if roster.Teams[1].Slots[0].PlayerID != "reserve" {
t.Fatal("dragging into an empty role slot did not move the player")
}
}
func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
rules := testRules()
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)

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