From f468f9df82e2b91a830d027310bea0d41490d1a4 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 02:36:40 +0300 Subject: [PATCH] Add team renaming functionality to API and frontend. Implement backend logic for renaming teams, including validation and synchronization with event rosters. Update frontend components to allow team name editing and ensure proper state management. Enhance translations and styles for new features. --- backend/internal/adapter/httpapi/server.go | 13 ++++++ backend/internal/adapter/postgres/store.go | 46 ++++++++++++++++++++++ backend/internal/application/service.go | 30 ++++++++++++++ frontend/src/App.tsx | 27 ++++++++++++- frontend/src/api/client.test.ts | 13 ++++++ frontend/src/api/client.ts | 2 + frontend/src/i18n.tsx | 2 + frontend/src/index.css | 2 + memory_bank/domain_model.md | 2 +- openapi/openapi.yaml | 19 +++++++++ 10 files changed, 153 insertions(+), 3 deletions(-) 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]