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
{ event.preventDefault(); rename.mutate() }}>{rename.isError && {rename.error.message}}
} 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]