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

{label}

Syncing with the server.

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

Couldn’t load this view

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

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

Couldn’t load this view

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

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

{demoEvents[2].title}

Semifinal 1 · Map 2 draft in progress

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

Coming up

{visible.length} scheduled events

{visible.length === 0 ?

No events yet

New community nights will appear here.

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

{event.title}

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

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

Are you playing?

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

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

{rsvpMutation.error.message}

}
- +

Registered players

Everyone can see who responded and their current status.

{attendees.length === 0 ?

No responses yet.

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

{profile.data.displayName}

{profile.data.battleTag}

Connected via Discord

Competitive ranks

Select your current Overwatch rank for each role.

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

Preferred roles

Choose every role you enjoy playing.

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

Preferred teammates

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

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

Avoided teammates

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

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

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

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

Moderators

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

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

{update.error.message}

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

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

Scheduling and registration

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