diff --git a/backend/internal/adapter/httpapi/server.go b/backend/internal/adapter/httpapi/server.go
index 8324b2b..01b90f2 100644
--- a/backend/internal/adapter/httpapi/server.go
+++ b/backend/internal/adapter/httpapi/server.go
@@ -96,6 +96,7 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
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.Put("/api/teams/{teamID}/name", s.renameTeam)
api.Post("/api/rulesets", s.saveRuleset)
api.Get("/api/rulesets", s.listRulesets)
api.Get("/api/rulesets/{id}", s.getRuleset)
@@ -379,6 +380,18 @@ func (s *Server) assignCaptain(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200)
}
+func (s *Server) renameTeam(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Name string `json:"name"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ id := who(r)
+ out, err := s.service.RenameTeam(r.Context(), id.account, id.player, chi.URLParam(r, "teamID"), in.Name)
+ respond(w, out, err, http.StatusOK)
+}
+
func (s *Server) saveRuleset(w http.ResponseWriter, r *http.Request) {
if err := requireStaff(r); err != nil {
writeError(w, err)
diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go
index 8ec1ce0..e95ea74 100644
--- a/backend/internal/adapter/postgres/store.go
+++ b/backend/internal/adapter/postgres/store.go
@@ -476,6 +476,52 @@ func (s *Store) ListTeams(ctx context.Context, eventID string) ([]domain.Team, e
return out, rows.Err()
}
+func (s *Store) GetTeam(ctx context.Context, teamID string) (domain.Team, error) {
+ var team domain.Team
+ var body []byte
+ err := s.pool.QueryRow(ctx, `SELECT id,event_id,name,COALESCE(captain_player_id,''),slots FROM teams WHERE id=$1`, teamID).
+ Scan(&team.ID, &team.EventID, &team.Name, &team.CaptainPlayerID, &body)
+ if err == nil {
+ err = json.Unmarshal(body, &team.Slots)
+ }
+ return team, mapError(err)
+}
+
+func (s *Store) RenameTeam(ctx context.Context, teamID, name string) (domain.Team, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return domain.Team{}, err
+ }
+ defer tx.Rollback(ctx)
+ var eventID string
+ if err = tx.QueryRow(ctx, `UPDATE teams SET name=$2 WHERE id=$1 RETURNING event_id`, teamID, name).Scan(&eventID); err != nil {
+ return domain.Team{}, mapError(err)
+ }
+ var rosterBody []byte
+ err = tx.QueryRow(ctx, `SELECT body FROM event_rosters WHERE event_id=$1`, eventID).Scan(&rosterBody)
+ if err == nil {
+ var roster domain.RosterDraft
+ if err = json.Unmarshal(rosterBody, &roster); err != nil {
+ return domain.Team{}, err
+ }
+ for index := range roster.Teams {
+ if roster.Teams[index].ID == teamID {
+ roster.Teams[index].Name = name
+ }
+ }
+ rosterBody, _ = json.Marshal(roster)
+ if _, err = tx.Exec(ctx, `UPDATE event_rosters SET body=$2,updated_at=now() WHERE event_id=$1`, eventID, rosterBody); err != nil {
+ return domain.Team{}, err
+ }
+ } else if !errors.Is(err, pgx.ErrNoRows) {
+ return domain.Team{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return domain.Team{}, err
+ }
+ return s.GetTeam(ctx, teamID)
+}
+
func (s *Store) AssignCaptain(ctx context.Context, teamID, playerID string) (domain.Team, error) {
var eventID string
if err := s.pool.QueryRow(ctx, `SELECT event_id FROM teams WHERE id=$1 AND slots @> $2::jsonb`, teamID, fmt.Sprintf(`[{"playerId":%q}]`, playerID)).Scan(&eventID); err != nil {
diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go
index d7640f2..f1fa504 100644
--- a/backend/internal/application/service.go
+++ b/backend/internal/application/service.go
@@ -37,6 +37,8 @@ type Store interface {
DeleteRSVP(context.Context, string, string) error
SaveTeams(context.Context, string, []domain.Team) error
ListTeams(context.Context, string) ([]domain.Team, error)
+ GetTeam(context.Context, string) (domain.Team, error)
+ RenameTeam(context.Context, string, string) (domain.Team, error)
AssignCaptain(context.Context, string, string) (domain.Team, error)
SaveRuleset(context.Context, domain.Ruleset) (domain.Ruleset, error)
ListRulesets(context.Context) ([]domain.Ruleset, error)
@@ -339,6 +341,34 @@ func (s *Service) AssignCaptain(ctx context.Context, actor domain.Account, teamI
return team, err
}
+func (s *Service) RenameTeam(ctx context.Context, actor domain.Account, player domain.Player, teamID, name string) (domain.Team, error) {
+ team, err := s.Store.GetTeam(ctx, teamID)
+ if err != nil {
+ return team, err
+ }
+ event, err := s.Store.GetEvent(ctx, team.EventID)
+ if err != nil {
+ return team, err
+ }
+ if event.State != domain.Live {
+ return team, fmt.Errorf("%w: team names can only be changed during a live scrim", domain.ErrConflict)
+ }
+ if !CanActForTeam(actor, player, team) {
+ return team, domain.ErrForbidden
+ }
+ name = strings.TrimSpace(name)
+ if len([]rune(name)) < 2 || len([]rune(name)) > 32 {
+ return team, fmt.Errorf("%w: team name must contain 2 to 32 characters", domain.ErrInvalid)
+ }
+ team, err = s.Store.RenameTeam(ctx, teamID, name)
+ if err == nil {
+ _ = s.Store.AppendAudit(ctx, actor.ID, "team.renamed", teamID, map[string]string{"name": name})
+ s.Bus.Publish("team:"+teamID, team)
+ s.Bus.Publish("event:"+team.EventID, team)
+ }
+ return team, err
+}
+
func CanActForTeam(actor domain.Account, player domain.Player, team domain.Team) bool {
return actor.IsStaff() || (team.CaptainPlayerID != "" && team.CaptainPlayerID == player.ID)
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 653dfa8..5e35d68 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,7 +7,7 @@ import {
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Link, Outlet, RouterProvider, createRootRouteWithContext, createRoute,
- createRouter, redirect,
+ createRouter, redirect, useRouterState,
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
@@ -81,6 +81,7 @@ function AppShell() {
void client.invalidateQueries({ queryKey: ['tournament'] })
} else if (scope === 'team') {
void client.invalidateQueries({ queryKey: ['teams'] })
+ void client.invalidateQueries({ queryKey: ['series'] })
} else if (message.topic === 'events') {
void client.invalidateQueries({ queryKey: ['events'] })
} else if (message.topic === 'players') {
@@ -645,9 +646,31 @@ function LiveSeriesPage() {
}
function Scoreboard({ series }: { series: Series }) {
+ const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
+ const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity })
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player.displayName
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
- return Team Alpha
{series.teamAlpha.name}
{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}
Best of 3{series.score.alpha}:{series.score.beta}{series.status}
Team Beta
{series.teamBeta.name}
{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}
+ const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
+ const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
+ return <>Team Alpha
{series.teamAlpha.name}
{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}Best of 3{series.score.alpha}:{series.score.beta}{series.status}
Team Beta
{series.teamBeta.name}
{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}{(canRenameAlpha || canRenameBeta) && {canRenameAlpha && }{canRenameBeta && }
}>
+}
+
+function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
+ const client = useQueryClient()
+ const [name, setName] = useState(team.name)
+ useEffect(() => setName(team.name), [team.name])
+ const rename = useMutation({
+ mutationFn: () => api.renameTeam(team.id, name),
+ onSuccess: (updated) => {
+ client.setQueryData(['series', seriesId], (current) => current ? {
+ ...current,
+ teamAlpha: current.teamAlpha.id === updated.id ? { ...current.teamAlpha, name: updated.name } : current.teamAlpha,
+ teamBeta: current.teamBeta.id === updated.id ? { ...current.teamBeta, name: updated.name } : current.teamBeta,
+ } : current)
+ void client.invalidateQueries({ queryKey: ['teams'] })
+ },
+ })
+ return
}
function MapTimeline({ series }: { series: Series }) { return Map timeline
First to 2 wins
{series.maps.length === 0 ? No results recorded.
: series.maps.map((map) => 0{map.number}{map.name}{map.mode} · {map.status}
{map.winner &&
{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'}}
)} }
function AuditLog({ series }: { series: Series }) { return Match history
Auditable server actions
{series.audit.length === 0 ? No actions yet.
: series.audit.slice(-4).reverse().map((a) => {a.summary}{a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
)} }
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
index 5bb8f27..9b8f8f9 100644
--- a/frontend/src/api/client.test.ts
+++ b/frontend/src/api/client.test.ts
@@ -120,4 +120,17 @@ describe('actual backend routes', () => {
body: JSON.stringify({ moderator: true }),
}))
})
+
+ it('renames a live team through its team route', async () => {
+ const team = { id: 'team-a', eventId: 'event-1', name: 'Ember Wolves', captainPlayerId: 'player-1', slots: [] }
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(team), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }))
+ await api.renameTeam(team.id, team.name)
+ expect(fetchMock).toHaveBeenCalledWith('/api/teams/team-a/name', expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ name: 'Ember Wolves' }),
+ }))
+ })
})
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 10adaa1..bf3895e 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -615,6 +615,8 @@ export const api = {
},
assignCaptain: async (teamId: string, playerId: string) =>
adaptTeam(await request(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()),
+ renameTeam: (teamId: string, name: string) =>
+ request(`/teams/${teamId}/name`, { method: 'PUT', body: JSON.stringify({ name }) }),
series: async (seriesId: string) => {
const series = await request(`/series/${seriesId}`)
return adaptSeriesWithTeams(series)
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index 39fb2ad..362f24a 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -51,6 +51,8 @@ const translations: Record = {
'All': 'Все',
'Live now': 'Сейчас в эфире',
'The scrim has started': 'Скрим начался',
+ 'Team name': 'Название команды',
+ 'Rename': 'Переименовать',
'Watch live': 'Смотреть',
'Coming up': 'Скоро',
'Filter events': 'Фильтровать события',
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 0470ff4..8f5da2e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -254,6 +254,7 @@ textarea { min-height: 85px; resize: vertical; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
.series-score { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }.series-score > span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; }.series-score strong { font-size: 48px; line-height: 1.25; }
+.team-name-controls { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 12px; }.team-name-editor { display: flex; align-items: end; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }.team-name-editor label { flex: 1; display: grid; gap: 5px; }.team-name-editor label span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }.team-name-editor input { width: 100%; }.team-name-editor .error-note { align-self: center; }
.live-layout { display: grid; grid-template-columns: 1fr 330px; gap: 16px; margin-top: 16px; align-items: start; }
.turn-card { border: 1px solid color-mix(in srgb,var(--team) 55%,#2b3038); border-top: 3px solid var(--team); border-radius: 12px; background: linear-gradient(155deg,var(--team-bg),var(--surface) 42%); overflow: hidden; }
.turn-header { padding: 26px; display: flex; align-items: start; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }.turn-header h2 { font-size: 27px; margin: 7px 0; }.turn-header p { color: var(--muted); font-size: 12px; }
@@ -311,6 +312,7 @@ textarea { min-height: 85px; resize: vertical; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-editor-body { grid-template-columns: 1fr; }.roster-slot { grid-template-columns: 24px 1fr auto 30px; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
+ .team-name-controls { grid-template-columns: 1fr; }.team-name-editor { align-items: stretch; flex-direction: column; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md
index 4f978a8..1561fc8 100644
--- a/memory_bank/domain_model.md
+++ b/memory_bank/domain_model.md
@@ -37,7 +37,7 @@
### Team
-Состав игроков, назначенный Admin капитан, цвет/название и рассчитанные показатели силы. Капитан должен входить в текущий состав команды; переназначение фиксируется в аудите.
+Состав игроков, назначенный staff-пользователем капитан, цвет/название и рассчитанные показатели силы. Капитан должен входить в текущий состав команды; переназначение фиксируется в аудите. После запуска скрима капитан может задать своей команде отображаемое название; staff может переименовать любую команду, изменение синхронизируется с live- и spectator-экранами.
- общий средний рейтинг;
- средний рейтинг по каждой роли;
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index f71d233..27deec5 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -559,6 +559,25 @@ paths:
$ref: "#/components/schemas/Team"
"422":
$ref: "#/components/responses/ValidationError"
+ /api/teams/{teamId}/name:
+ put:
+ tags: [Teams]
+ operationId: renameLiveTeam
+ parameters:
+ - $ref: "#/components/parameters/TeamId"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [name]
+ properties:
+ name: { type: string, minLength: 2, maxLength: 32 }
+ responses:
+ "200": { description: Team renamed, content: { application/json: { schema: { $ref: "#/components/schemas/Team" } } } }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "409": { $ref: "#/components/responses/Conflict" }
/api/coin-toss:
post:
tags: [Series]