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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user