diff --git a/backend/internal/adapter/httpapi/server.go b/backend/internal/adapter/httpapi/server.go
index 0efda5f..4c17241 100644
--- a/backend/internal/adapter/httpapi/server.go
+++ b/backend/internal/adapter/httpapi/server.go
@@ -65,6 +65,9 @@ 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/players/{playerID}", s.playerProfile)
+ api.Get("/api/community/settings", s.communitySettings)
+ api.Put("/api/community/settings", s.updateCommunitySettings)
api.Get("/api/accounts", s.accounts)
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
api.Get("/api/events", s.events)
@@ -246,7 +249,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var in struct {
- DisplayName string `json:"displayName"`
+ DisplayName *string `json:"displayName"`
+ BattleTag *string `json:"battleTag"`
Ratings domain.Ratings `json:"ratings"`
PreferredRoles []domain.Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -256,7 +260,10 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
return
}
id := who(r)
- out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
+ out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, application.UpdateOwnProfileInput{
+ DisplayName: in.DisplayName, BattleTag: in.BattleTag, Ratings: in.Ratings,
+ PreferredRoles: in.PreferredRoles, PreferredPlayerIDs: in.PreferredPlayerIDs, AvoidedPlayerIDs: in.AvoidedPlayerIDs,
+ })
respond(w, out, err, http.StatusOK)
}
@@ -265,6 +272,27 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200)
}
+func (s *Server) playerProfile(w http.ResponseWriter, r *http.Request) {
+ out, err := s.service.GetPlayerProfile(r.Context(), chi.URLParam(r, "playerID"))
+ respond(w, out, err, http.StatusOK)
+}
+
+func (s *Server) communitySettings(w http.ResponseWriter, r *http.Request) {
+ out, err := s.service.GetCommunitySettings(r.Context())
+ respond(w, out, err, http.StatusOK)
+}
+
+func (s *Server) updateCommunitySettings(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ DiscordInviteURL string `json:"discordInviteUrl"`
+ }
+ if !decode(w, r, &input) {
+ return
+ }
+ out, err := s.service.UpdateCommunitySettings(r.Context(), who(r).account, input.DiscordInviteURL)
+ respond(w, out, err, http.StatusOK)
+}
+
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)
diff --git a/backend/internal/adapter/httpapi/server_test.go b/backend/internal/adapter/httpapi/server_test.go
index e344727..ee4e754 100644
--- a/backend/internal/adapter/httpapi/server_test.go
+++ b/backend/internal/adapter/httpapi/server_test.go
@@ -1,6 +1,18 @@
package httpapi
-import "testing"
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "mixmaker/backend/internal/application"
+ "mixmaker/backend/internal/domain"
+ "mixmaker/backend/internal/realtime"
+)
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
if !((createEventRequest{}).options().PingEveryone) {
@@ -14,3 +26,67 @@ func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
t.Fatal("explicit false pingEveryone must disable the ping")
}
}
+
+type profileHTTPStore struct {
+ application.Store
+ account domain.Account
+ player domain.Player
+}
+
+func (s *profileHTTPStore) AccountBySession(context.Context, string) (domain.Account, domain.Player, error) {
+ return s.account, s.player, nil
+}
+
+func (s *profileHTTPStore) GetPlayer(context.Context, string) (domain.Player, error) {
+ return s.player, nil
+}
+
+func (s *profileHTTPStore) ListEvents(context.Context, time.Time) ([]domain.Event, error) {
+ return []domain.Event{}, nil
+}
+
+func TestPlayerProfileHTTPResponseIsPublicSafe(t *testing.T) {
+ store := &profileHTTPStore{
+ account: domain.Account{ID: "account", Role: domain.RolePlayer},
+ player: domain.Player{
+ ID: "player", AccountID: "account", DisplayName: "Ana", BattleTag: "Ana#1234",
+ Ratings: domain.Ratings{Tank: 10, Damage: 20, Support: 30},
+ PreferredPlayerIDs: []string{"secret-preference"}, AvoidedPlayerIDs: []string{"secret-avoid"},
+ },
+ }
+ hub := realtime.New()
+ handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
+ request := httptest.NewRequest(http.MethodGet, "/api/players/player", nil)
+ request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
+ }
+ var body map[string]any
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ encoded := response.Body.String()
+ if strings.Contains(encoded, "preferred") || strings.Contains(encoded, "avoid") || strings.Contains(encoded, "accountId") {
+ t.Fatalf("profile leaked private fields: %s", encoded)
+ }
+}
+
+func TestModeratorCannotUpdateCommunitySettings(t *testing.T) {
+ store := &profileHTTPStore{
+ account: domain.Account{ID: "moderator", Role: domain.RoleModerator},
+ player: domain.Player{ID: "player", AccountID: "moderator"},
+ }
+ hub := realtime.New()
+ handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
+ request := httptest.NewRequest(http.MethodPut, "/api/community/settings", strings.NewReader(`{"discordInviteUrl":""}`))
+ request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+
+ if response.Code != http.StatusForbidden {
+ t.Fatalf("moderator update returned %d: %s", response.Code, response.Body.String())
+ }
+}
diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go
index 20e0220..56eed99 100644
--- a/backend/internal/adapter/postgres/store.go
+++ b/backend/internal/adapter/postgres/store.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"mixmaker/backend/internal/application"
@@ -62,13 +63,27 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
if err != nil {
return domain.Account{}, domain.Player{}, err
}
- p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
+ p, err := playerByAccount(ctx, tx, account.ID)
+ if err == nil {
+ return account, p, tx.Commit(ctx)
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return domain.Account{}, domain.Player{}, err
+ }
+ if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('mixmaker-player-display-names'))`); err != nil {
+ return domain.Account{}, domain.Player{}, err
+ }
+ displayName, err := uniqueDefaultDisplayName(ctx, tx, account.Username)
+ if err != nil {
+ return domain.Account{}, domain.Player{}, err
+ }
+ p = domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: displayName, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
var preferredRoles, preferredPlayers, avoidedPlayers []byte
- err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
- VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
- RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
+ err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
+ VALUES($1,$2,$3,'',$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
+ RETURNING id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
- Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
+ Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return domain.Account{}, domain.Player{}, err
}
@@ -84,6 +99,51 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
return account, p, tx.Commit(ctx)
}
+func playerByAccount(ctx context.Context, tx pgx.Tx, accountID string) (domain.Player, error) {
+ var p domain.Player
+ var preferredRoles, preferredPlayers, avoidedPlayers []byte
+ err := tx.QueryRow(ctx, `SELECT id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,
+ preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE account_id=$1`, accountID).
+ Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
+ &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
+ if err != nil {
+ return p, err
+ }
+ if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err == nil {
+ err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
+ }
+ if err == nil {
+ err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
+ }
+ return p, err
+}
+
+func uniqueDefaultDisplayName(ctx context.Context, tx pgx.Tx, username string) (string, error) {
+ baseRunes := []rune(strings.TrimSpace(username))
+ if len(baseRunes) < 2 {
+ baseRunes = []rune("Player")
+ }
+ if len(baseRunes) > 32 {
+ baseRunes = baseRunes[:32]
+ }
+ base := string(baseRunes)
+ for number := 1; ; number++ {
+ candidate := base
+ if number > 1 {
+ suffix := fmt.Sprintf("-%d", number)
+ limit := 32 - len([]rune(suffix))
+ candidate = string([]rune(base)[:min(len([]rune(base)), limit)]) + suffix
+ }
+ var exists bool
+ if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM players WHERE lower(display_name)=lower($1))`, candidate).Scan(&exists); err != nil {
+ return "", err
+ }
+ if !exists {
+ return candidate, nil
+ }
+ }
+}
+
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 {
@@ -124,11 +184,11 @@ func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Acco
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
- p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
+ p.id,p.account_id,p.display_name,p.battle_tag,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
- &p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
+ &p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
@@ -145,16 +205,35 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe
preferredRoles, _ := json.Marshal(p.PreferredRoles)
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
- err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
- preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9
- WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
- p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
- Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
+ err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,battle_tag=$3,tank_rating=$4,damage_rating=$5,support_rating=$6,
+ preferred_roles=$7,preferred_player_ids=$8,avoided_player_ids=$9,updated_at=$10
+ WHERE id=$1 RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at`,
+ p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
+ Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
+ return p, mapError(err)
+}
+
+func (s *Store) GetPlayer(ctx context.Context, id string) (domain.Player, error) {
+ var p domain.Player
+ var preferredRoles, preferredPlayers, avoidedPlayers []byte
+ err := s.pool.QueryRow(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
+ preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE id=$1`, id).
+ Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
+ &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
+ if err == nil {
+ err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
+ }
+ if err == nil {
+ err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
+ }
+ if err == nil {
+ err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
+ }
return p, mapError(err)
}
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
- rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
+ rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
if err != nil {
return nil, err
@@ -164,7 +243,7 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
for rows.Next() {
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
- if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
+ if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
@@ -189,11 +268,11 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
}
defer tx.Rollback(ctx)
var preferredRoles, preferredPlayers, avoidedPlayers []byte
- err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
- VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
- RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
- p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
- Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
+ err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
+ VALUES($1,NULL,$2,$3,$4,$5,$6,$7,$8)
+ RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
+ p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
+ Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
@@ -638,6 +717,27 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
return out, mapError(err)
}
+func (s *Store) ListSeriesByEvent(ctx context.Context, eventID string) ([]domain.Series, error) {
+ rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE body->>'eventId'=$1 ORDER BY id`, eventID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := make([]domain.Series, 0)
+ for rows.Next() {
+ var body []byte
+ var series domain.Series
+ if err := rows.Scan(&body); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(body, &series); err != nil {
+ return nil, err
+ }
+ out = append(out, series)
+ }
+ return out, rows.Err()
+}
+
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
body, _ := json.Marshal(t)
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
@@ -749,6 +849,20 @@ func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, in
return kind, version, mapError(err)
}
+func (s *Store) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
+ var settings domain.CommunitySettings
+ err := s.pool.QueryRow(ctx, `SELECT discord_invite_url,updated_at,COALESCE(updated_by,'') FROM community_settings WHERE singleton=true`).
+ Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
+ return settings, mapError(err)
+}
+
+func (s *Store) UpdateCommunitySettings(ctx context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
+ err := s.pool.QueryRow(ctx, `UPDATE community_settings SET discord_invite_url=$1,updated_at=$2,updated_by=$3 WHERE singleton=true
+ RETURNING discord_invite_url,updated_at,updated_by`, settings.DiscordInviteURL, settings.UpdatedAt, settings.UpdatedBy).
+ Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
+ return settings, mapError(err)
+}
+
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
body, _ := json.Marshal(payload)
_, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body)
@@ -806,5 +920,9 @@ func mapError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound
}
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return fmt.Errorf("%w: value already exists", domain.ErrConflict)
+ }
return err
}
diff --git a/backend/internal/adapter/postgres/store_integration_test.go b/backend/internal/adapter/postgres/store_integration_test.go
index 6673251..b88b570 100644
--- a/backend/internal/adapter/postgres/store_integration_test.go
+++ b/backend/internal/adapter/postgres/store_integration_test.go
@@ -63,6 +63,24 @@ func TestMigrationsAndReadiness(t *testing.T) {
t.Fatalf("%s table is missing", table)
}
}
+ var identityColumns int
+ if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
+ WHERE table_name='players' AND column_name IN ('display_name','battle_tag')`).Scan(&identityColumns); err != nil {
+ t.Fatal(err)
+ }
+ if identityColumns != 2 {
+ t.Fatalf("player identity migration is incomplete: found %d columns", identityColumns)
+ }
+ var settingsTable, nicknameIndex, seriesEventIndex bool
+ if err := store.pool.QueryRow(ctx, `SELECT
+ to_regclass('public.community_settings') IS NOT NULL,
+ to_regclass('public.players_display_name_lower_uidx') IS NOT NULL,
+ to_regclass('public.series_event_id_idx') IS NOT NULL`).Scan(&settingsTable, &nicknameIndex, &seriesEventIndex); err != nil {
+ t.Fatal(err)
+ }
+ if !settingsTable || !nicknameIndex || !seriesEventIndex {
+ t.Fatalf("profile/settings migrations missing: settings=%v nickname=%v series=%v", settingsTable, nicknameIndex, seriesEventIndex)
+ }
}
func TestDiscordRoleSyncPersistence(t *testing.T) {
diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go
index 0befd46..e36eb6a 100644
--- a/backend/internal/application/service.go
+++ b/backend/internal/application/service.go
@@ -20,6 +20,7 @@ type Store interface {
CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
+ GetPlayer(context.Context, string) (domain.Player, error)
ListPlayers(context.Context) ([]domain.Player, error)
EnqueueDiscordRoleSync(context.Context, string, string, int64) error
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
@@ -49,11 +50,14 @@ type Store interface {
GetRuleset(context.Context, string) (domain.Ruleset, error)
SaveSeries(context.Context, domain.Series) (domain.Series, error)
GetSeries(context.Context, string) (domain.Series, error)
+ ListSeriesByEvent(context.Context, string) ([]domain.Series, error)
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
GetTournament(context.Context, string) (domain.Tournament, error)
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
SaveDraft(context.Context, string, string, any, int) error
GetDraft(context.Context, string, any) (string, int, error)
+ GetCommunitySettings(context.Context) (domain.CommunitySettings, error)
+ UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error)
AppendAudit(context.Context, string, string, string, any) error
}
@@ -125,20 +129,35 @@ func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accoun
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) {
+type UpdateOwnProfileInput struct {
+ DisplayName *string
+ BattleTag *string
+ Ratings domain.Ratings
+ PreferredRoles []domain.Role
+ PreferredPlayerIDs []string
+ AvoidedPlayerIDs []string
+}
+
+func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) {
if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden
}
- if err := ratings.Validate(); err != nil {
+ if err := input.Ratings.Validate(); err != nil {
return domain.Player{}, err
}
- if displayName != "" {
- current.DisplayName = displayName
+ if input.DisplayName != nil {
+ current.DisplayName = *input.DisplayName
}
- current.Ratings = ratings
- current.PreferredRoles = preferredRoles
- current.PreferredPlayerIDs = preferredPlayerIDs
- current.AvoidedPlayerIDs = avoidedPlayerIDs
+ if input.BattleTag != nil {
+ current.BattleTag = *input.BattleTag
+ }
+ if err := current.ValidateIdentity(); err != nil {
+ return domain.Player{}, err
+ }
+ current.Ratings = input.Ratings
+ current.PreferredRoles = input.PreferredRoles
+ current.PreferredPlayerIDs = input.PreferredPlayerIDs
+ current.AvoidedPlayerIDs = input.AvoidedPlayerIDs
current.UpdatedAt = s.Now()
if err := current.ValidatePreferences(); err != nil {
return domain.Player{}, err
@@ -168,6 +187,75 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
return out, err
}
+func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) {
+ player, err := s.Store.GetPlayer(ctx, playerID)
+ if err != nil {
+ return domain.PlayerProfile{}, err
+ }
+ events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC())
+ if err != nil {
+ return domain.PlayerProfile{}, err
+ }
+ matches := make([]domain.PlayerSeriesContext, 0)
+ for _, event := range events {
+ if event.State != domain.Completed {
+ continue
+ }
+ teams, listErr := s.Store.ListTeams(ctx, event.ID)
+ if listErr != nil {
+ return domain.PlayerProfile{}, listErr
+ }
+ var ownTeam *domain.Team
+ for index := range teams {
+ for _, slot := range teams[index].Slots {
+ if slot.PlayerID == playerID {
+ ownTeam = &teams[index]
+ break
+ }
+ }
+ }
+ if ownTeam == nil {
+ continue
+ }
+ series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID)
+ if listErr != nil {
+ return domain.PlayerProfile{}, listErr
+ }
+ byID := make(map[string]domain.Team, len(teams))
+ for _, team := range teams {
+ byID[team.ID] = team
+ }
+ for _, item := range series {
+ teamA, okA := byID[item.TeamAID]
+ teamB, okB := byID[item.TeamBID]
+ if okA && okB {
+ matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB})
+ }
+ }
+ }
+ return domain.BuildPlayerProfile(player, matches, 10), nil
+}
+
+func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
+ return s.Store.GetCommunitySettings(ctx)
+}
+
+func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) {
+ if !actor.IsAdmin() {
+ return domain.CommunitySettings{}, domain.ErrForbidden
+ }
+ settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID}
+ if err := settings.Validate(); err != nil {
+ return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err)
+ }
+ out, err := s.Store.UpdateCommunitySettings(ctx, settings)
+ if err == nil {
+ _ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out)
+ s.Bus.Publish("community-settings", out)
+ }
+ return out, err
+}
+
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
@@ -300,6 +388,9 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
CreatedAt: now,
UpdatedAt: now,
}
+ if err := player.ValidateIdentity(); err != nil {
+ return domain.Player{}, domain.RSVP{}, err
+ }
rsvp := domain.RSVP{
EventID: eventID,
PlayerID: player.ID,
diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go
index 735b066..871ed42 100644
--- a/backend/internal/application/service_test.go
+++ b/backend/internal/application/service_test.go
@@ -121,6 +121,85 @@ func TestCreateEventCanAnnounceWithoutEveryonePing(t *testing.T) {
}
}
+type communitySettingsStore struct {
+ Store
+ saved domain.CommunitySettings
+ audits int
+}
+
+func (s *communitySettingsStore) UpdateCommunitySettings(_ context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
+ s.saved = settings
+ return settings, nil
+}
+
+func (s *communitySettingsStore) AppendAudit(_ context.Context, _, _, _ string, _ any) error {
+ s.audits++
+ return nil
+}
+
+func TestUpdateCommunitySettingsRequiresAdminAndAudits(t *testing.T) {
+ store := &communitySettingsStore{}
+ service := New(store, &recordingPublisher{}, nil)
+ now := time.Date(2026, time.July, 19, 12, 0, 0, 0, time.UTC)
+ service.Now = func() time.Time { return now }
+
+ if _, err := service.UpdateCommunitySettings(context.Background(), domain.Account{Role: domain.RoleModerator}, ""); !errors.Is(err, domain.ErrForbidden) {
+ t.Fatalf("moderator must be forbidden, got %v", err)
+ }
+ out, err := service.UpdateCommunitySettings(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, " https://discord.gg/mixmaker ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.DiscordInviteURL != "https://discord.gg/mixmaker" || out.UpdatedBy != "admin" || !out.UpdatedAt.Equal(now) {
+ t.Fatalf("unexpected settings: %+v", out)
+ }
+ if store.audits != 1 {
+ t.Fatalf("expected one audit entry, got %d", store.audits)
+ }
+}
+
+type playerProfileStore struct {
+ Store
+ players []domain.Player
+ saved domain.Player
+ err error
+}
+
+func (s *playerProfileStore) ListPlayers(context.Context) ([]domain.Player, error) {
+ return s.players, nil
+}
+
+func (s *playerProfileStore) UpdatePlayer(_ context.Context, player domain.Player) (domain.Player, error) {
+ s.saved = player
+ return player, s.err
+}
+
+func TestUpdateOwnProfileTrimsIdentityAndClearsBattleTag(t *testing.T) {
+ store := &playerProfileStore{players: []domain.Player{{ID: "player"}}}
+ service := New(store, &recordingPublisher{}, nil)
+ name, battleTag := " New Name ", ""
+ out, err := service.UpdateOwnProfile(context.Background(),
+ domain.Account{ID: "account"},
+ domain.Player{ID: "player", AccountID: "account", DisplayName: "Old", BattleTag: "Old#1234"},
+ UpdateOwnProfileInput{
+ DisplayName: &name, BattleTag: &battleTag,
+ Ratings: domain.Ratings{Tank: 1, Damage: 20, Support: 40},
+ },
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.DisplayName != "New Name" || out.BattleTag != "" {
+ t.Fatalf("identity was not normalized and cleared: %+v", out)
+ }
+ if _, err = service.UpdateOwnProfile(context.Background(),
+ domain.Account{ID: "other"}, domain.Player{AccountID: "account"},
+ UpdateOwnProfileInput{},
+ ); !errors.Is(err, domain.ErrForbidden) {
+ t.Fatalf("profile updates must remain self-only, got %v", err)
+ }
+}
+
func validEvent(now time.Time) domain.Event {
return domain.Event{
Name: "Sunday Mix",
diff --git a/backend/internal/domain/model.go b/backend/internal/domain/model.go
index 050db20..49bec78 100644
--- a/backend/internal/domain/model.go
+++ b/backend/internal/domain/model.go
@@ -4,9 +4,11 @@ import (
"errors"
"fmt"
"math/rand"
+ "regexp"
"slices"
"strings"
"time"
+ "unicode/utf8"
)
var (
@@ -63,6 +65,7 @@ type Player struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
DisplayName string `json:"displayName"`
+ BattleTag string `json:"battleTag"`
Ratings Ratings `json:"ratings"`
PreferredRoles []Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -71,6 +74,21 @@ type Player struct {
UpdatedAt time.Time `json:"updatedAt"`
}
+var battleTagPattern = regexp.MustCompile(`^[^#\s]{2,20}#[0-9]{3,12}$`)
+
+func (p *Player) ValidateIdentity() error {
+ p.DisplayName = strings.TrimSpace(p.DisplayName)
+ length := utf8.RuneCountInString(p.DisplayName)
+ if length < 2 || length > 32 {
+ return fmt.Errorf("%w: display name must contain 2 to 32 characters", ErrInvalid)
+ }
+ p.BattleTag = strings.TrimSpace(p.BattleTag)
+ if p.BattleTag != "" && !battleTagPattern.MatchString(p.BattleTag) {
+ return fmt.Errorf("%w: BattleTag must have the form Name#digits", ErrInvalid)
+ }
+ return nil
+}
+
func (p Player) ValidatePreferences() error {
if len(p.PreferredPlayerIDs) > 3 {
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
diff --git a/backend/internal/domain/player_profile.go b/backend/internal/domain/player_profile.go
new file mode 100644
index 0000000..5011809
--- /dev/null
+++ b/backend/internal/domain/player_profile.go
@@ -0,0 +1,169 @@
+package domain
+
+import (
+ "sort"
+ "strings"
+ "time"
+)
+
+type PublicPlayer struct {
+ ID string `json:"id"`
+ DisplayName string `json:"displayName"`
+ BattleTag string `json:"battleTag"`
+ Ratings Ratings `json:"ratings"`
+ IsGuest bool `json:"isGuest"`
+}
+
+type PlayerStatsSummary struct {
+ SeriesPlayed int `json:"seriesPlayed"`
+ SeriesWon int `json:"seriesWon"`
+ SeriesLost int `json:"seriesLost"`
+ WinRate float64 `json:"winRate"`
+ MapsPlayed int `json:"mapsPlayed"`
+ MapsWon int `json:"mapsWon"`
+ MapsLost int `json:"mapsLost"`
+ MapsDrawn int `json:"mapsDrawn"`
+}
+
+type PlayerMatchHistory struct {
+ SeriesID string `json:"seriesId"`
+ EventID string `json:"eventId"`
+ EventName string `json:"eventName"`
+ EventDate time.Time `json:"eventDate"`
+ OwnTeamID string `json:"ownTeamId"`
+ OwnTeamName string `json:"ownTeamName"`
+ OpponentTeamID string `json:"opponentTeamId"`
+ OpponentTeamName string `json:"opponentTeamName"`
+ OwnScore int `json:"ownScore"`
+ OpponentScore int `json:"opponentScore"`
+ Outcome string `json:"outcome"`
+}
+
+type PlayerProfile struct {
+ Player PublicPlayer `json:"player"`
+ Summary PlayerStatsSummary `json:"summary"`
+ Recent []PlayerMatchHistory `json:"recentMatches"`
+}
+
+type PlayerSeriesContext struct {
+ Event Event
+ Series Series
+ TeamA Team
+ TeamB Team
+}
+
+func BuildPlayerProfile(player Player, matches []PlayerSeriesContext, recentLimit int) PlayerProfile {
+ profile := PlayerProfile{
+ Player: PublicPlayer{
+ ID: player.ID, DisplayName: player.DisplayName, BattleTag: player.BattleTag,
+ Ratings: player.Ratings, IsGuest: player.AccountID == "",
+ },
+ Recent: []PlayerMatchHistory{},
+ }
+ for _, match := range matches {
+ if match.Event.State != Completed || match.Series.Phase != SeriesComplete || match.Series.WinnerTeamID == "" {
+ continue
+ }
+ own, opponent, ownIsA := teamContaining(match.TeamA, player.ID), match.TeamB, true
+ if own.ID == "" {
+ own, opponent, ownIsA = teamContaining(match.TeamB, player.ID), match.TeamA, false
+ }
+ if own.ID == "" || opponent.ID == "" {
+ continue
+ }
+ ownScore, opponentScore, mapsWon, mapsLost, mapsDrawn := scoreEffectiveResults(match.Series, ownIsA)
+ profile.Summary.SeriesPlayed++
+ profile.Summary.MapsPlayed += mapsWon + mapsLost + mapsDrawn
+ profile.Summary.MapsWon += mapsWon
+ profile.Summary.MapsLost += mapsLost
+ profile.Summary.MapsDrawn += mapsDrawn
+ outcome := "loss"
+ if match.Series.WinnerTeamID == own.ID {
+ outcome = "win"
+ profile.Summary.SeriesWon++
+ } else {
+ profile.Summary.SeriesLost++
+ }
+ profile.Recent = append(profile.Recent, PlayerMatchHistory{
+ SeriesID: match.Series.ID, EventID: match.Event.ID, EventName: match.Event.Name, EventDate: match.Event.StartsAt,
+ OwnTeamID: own.ID, OwnTeamName: own.Name, OpponentTeamID: opponent.ID, OpponentTeamName: opponent.Name,
+ OwnScore: ownScore, OpponentScore: opponentScore, Outcome: outcome,
+ })
+ }
+ if profile.Summary.SeriesPlayed > 0 {
+ profile.Summary.WinRate = float64(profile.Summary.SeriesWon) / float64(profile.Summary.SeriesPlayed)
+ }
+ sort.SliceStable(profile.Recent, func(i, j int) bool {
+ if profile.Recent[i].EventDate.Equal(profile.Recent[j].EventDate) {
+ return profile.Recent[i].SeriesID > profile.Recent[j].SeriesID
+ }
+ return profile.Recent[i].EventDate.After(profile.Recent[j].EventDate)
+ })
+ if recentLimit >= 0 && len(profile.Recent) > recentLimit {
+ profile.Recent = profile.Recent[:recentLimit]
+ }
+ return profile
+}
+
+func teamContaining(team Team, playerID string) Team {
+ for _, slot := range team.Slots {
+ if slot.PlayerID == playerID {
+ return team
+ }
+ }
+ return Team{}
+}
+
+func scoreEffectiveResults(series Series, ownIsA bool) (ownScore, opponentScore, won, lost, drawn int) {
+ superseded := make(map[int]bool)
+ for _, result := range series.Results {
+ if result.CorrectionOf > 0 {
+ superseded[result.CorrectionOf] = true
+ }
+ }
+ for index, result := range series.Results {
+ if superseded[index+1] {
+ continue
+ }
+ switch result.Outcome {
+ case Draw:
+ drawn++
+ case TeamAWin:
+ if ownIsA {
+ ownScore, won = ownScore+1, won+1
+ } else {
+ opponentScore, lost = opponentScore+1, lost+1
+ }
+ case TeamBWin:
+ if ownIsA {
+ opponentScore, lost = opponentScore+1, lost+1
+ } else {
+ ownScore, won = ownScore+1, won+1
+ }
+ }
+ }
+ return ownScore, opponentScore, won, lost, drawn
+}
+
+type CommunitySettings struct {
+ DiscordInviteURL string `json:"discordInviteUrl"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ UpdatedBy string `json:"updatedBy"`
+}
+
+func (settings *CommunitySettings) Validate() error {
+ settings.DiscordInviteURL = strings.TrimSpace(settings.DiscordInviteURL)
+ if settings.DiscordInviteURL == "" {
+ return nil
+ }
+ if !strings.HasPrefix(settings.DiscordInviteURL, "https://discord.gg/") &&
+ !strings.HasPrefix(settings.DiscordInviteURL, "https://discord.com/invite/") {
+ return ErrInvalid
+ }
+ code := strings.TrimPrefix(settings.DiscordInviteURL, "https://discord.gg/")
+ code = strings.TrimPrefix(code, "https://discord.com/invite/")
+ if code == "" || strings.ContainsAny(code, "/?# \t\r\n") {
+ return ErrInvalid
+ }
+ return nil
+}
diff --git a/backend/internal/domain/player_profile_test.go b/backend/internal/domain/player_profile_test.go
new file mode 100644
index 0000000..7668af4
--- /dev/null
+++ b/backend/internal/domain/player_profile_test.go
@@ -0,0 +1,80 @@
+package domain
+
+import (
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestPlayerIdentityValidation(t *testing.T) {
+ player := Player{DisplayName: " Ana Main ", BattleTag: "Ana#1234"}
+ if err := player.ValidateIdentity(); err != nil {
+ t.Fatal(err)
+ }
+ if player.DisplayName != "Ana Main" {
+ t.Fatalf("display name was not trimmed: %q", player.DisplayName)
+ }
+ player.DisplayName = "A"
+ if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
+ t.Fatal("one-character display name must be invalid")
+ }
+ player.DisplayName, player.BattleTag = "Valid", "invalid"
+ if !errors.Is(player.ValidateIdentity(), ErrInvalid) {
+ t.Fatal("invalid BattleTag must be rejected")
+ }
+ player.BattleTag = ""
+ if err := player.ValidateIdentity(); err != nil {
+ t.Fatalf("empty BattleTag must clear the value: %v", err)
+ }
+}
+
+func TestBuildPlayerProfileUsesEffectiveResultsAndFinalRoster(t *testing.T) {
+ start := time.Date(2026, time.July, 19, 18, 0, 0, 0, time.UTC)
+ event := Event{ID: "event", Name: "Sunday Mix", StartsAt: start, State: Completed}
+ teamA := Team{ID: "a", Name: "Alpha", Slots: []Slot{{PlayerID: "player"}}}
+ teamB := Team{ID: "b", Name: "Bravo", Slots: []Slot{{PlayerID: "opponent"}}}
+ series := Series{
+ ID: "series", EventID: event.ID, TeamAID: teamA.ID, TeamBID: teamB.ID,
+ WinnerTeamID: teamA.ID, Phase: SeriesComplete,
+ Results: []MapResult{
+ {Outcome: TeamBWin},
+ {Outcome: TeamAWin},
+ {Outcome: Draw},
+ {Outcome: TeamAWin, CorrectionOf: 1},
+ },
+ }
+ profile := BuildPlayerProfile(Player{ID: "player", DisplayName: "Player", AccountID: ""}, []PlayerSeriesContext{
+ {Event: event, Series: series, TeamA: teamA, TeamB: teamB},
+ {Event: Event{State: Live}, Series: series, TeamA: teamA, TeamB: teamB},
+ {Event: event, Series: series, TeamA: Team{ID: "a"}, TeamB: teamB},
+ }, 10)
+
+ if profile.Summary.SeriesPlayed != 1 || profile.Summary.SeriesWon != 1 || profile.Summary.WinRate != 1 {
+ t.Fatalf("unexpected series summary: %+v", profile.Summary)
+ }
+ if profile.Summary.MapsPlayed != 3 || profile.Summary.MapsWon != 2 ||
+ profile.Summary.MapsLost != 0 || profile.Summary.MapsDrawn != 1 {
+ t.Fatalf("corrected map result was not aggregated correctly: %+v", profile.Summary)
+ }
+ if len(profile.Recent) != 1 || profile.Recent[0].OwnScore != 2 || profile.Recent[0].OpponentScore != 0 {
+ t.Fatalf("unexpected history: %+v", profile.Recent)
+ }
+ if !profile.Player.IsGuest {
+ t.Fatal("account-less players must be represented as guests")
+ }
+}
+
+func TestCommunitySettingsValidation(t *testing.T) {
+ for _, value := range []string{"", "https://discord.gg/mixmaker", "https://discord.com/invite/mixmaker"} {
+ settings := CommunitySettings{DiscordInviteURL: value}
+ if err := settings.Validate(); err != nil {
+ t.Fatalf("%q should be valid: %v", value, err)
+ }
+ }
+ for _, value := range []string{"http://discord.gg/code", "https://example.com/invite", "https://discord.gg/", "https://discord.gg/code/path"} {
+ settings := CommunitySettings{DiscordInviteURL: value}
+ if !errors.Is(settings.Validate(), ErrInvalid) {
+ t.Fatalf("%q should be invalid", value)
+ }
+ }
+}
diff --git a/backend/migrations/015_player_identity.sql b/backend/migrations/015_player_identity.sql
new file mode 100644
index 0000000..357bef3
--- /dev/null
+++ b/backend/migrations/015_player_identity.sql
@@ -0,0 +1,53 @@
+-- +mixmaker Up
+ALTER TABLE players
+ ADD COLUMN IF NOT EXISTS battle_tag text NOT NULL DEFAULT '';
+
+DO $$
+DECLARE
+ item record;
+ base_name text;
+ candidate text;
+ suffix text;
+ duplicate_number integer;
+BEGIN
+ FOR item IN SELECT id, display_name FROM players ORDER BY created_at, id LOOP
+ base_name := regexp_replace(item.display_name, '^[[:space:]]+|[[:space:]]+$', '', 'g');
+ IF char_length(base_name) < 2 THEN
+ base_name := 'Player';
+ END IF;
+ base_name := left(base_name, 32);
+ candidate := base_name;
+ duplicate_number := 1;
+ WHILE EXISTS (
+ SELECT 1 FROM players
+ WHERE id <> item.id AND lower(display_name) = lower(candidate)
+ ) LOOP
+ duplicate_number := duplicate_number + 1;
+ suffix := '-' || duplicate_number::text;
+ candidate := left(base_name, 32 - char_length(suffix)) || suffix;
+ END LOOP;
+ UPDATE players SET display_name = candidate WHERE id = item.id;
+ END LOOP;
+END $$;
+
+ALTER TABLE players
+ DROP CONSTRAINT IF EXISTS players_display_name_valid,
+ ADD CONSTRAINT players_display_name_valid CHECK (
+ display_name = regexp_replace(display_name, '^[[:space:]]+|[[:space:]]+$', '', 'g')
+ AND char_length(display_name) BETWEEN 2 AND 32
+ ),
+ DROP CONSTRAINT IF EXISTS players_battle_tag_valid,
+ ADD CONSTRAINT players_battle_tag_valid CHECK (
+ battle_tag = ''
+ OR battle_tag ~ '^[^#[:space:]]{2,20}#[0-9]{3,12}$'
+ );
+
+CREATE UNIQUE INDEX IF NOT EXISTS players_display_name_lower_uidx
+ ON players (lower(display_name));
+
+-- +mixmaker Down
+DROP INDEX IF EXISTS players_display_name_lower_uidx;
+ALTER TABLE players
+ DROP CONSTRAINT IF EXISTS players_battle_tag_valid,
+ DROP CONSTRAINT IF EXISTS players_display_name_valid,
+ DROP COLUMN IF EXISTS battle_tag;
diff --git a/backend/migrations/016_community_settings_and_stats.sql b/backend/migrations/016_community_settings_and_stats.sql
new file mode 100644
index 0000000..097139d
--- /dev/null
+++ b/backend/migrations/016_community_settings_and_stats.sql
@@ -0,0 +1,20 @@
+-- +mixmaker Up
+CREATE TABLE community_settings (
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
+ discord_invite_url text NOT NULL DEFAULT '',
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ updated_by text REFERENCES accounts(id)
+);
+
+INSERT INTO community_settings(singleton) VALUES (true)
+ON CONFLICT(singleton) DO NOTHING;
+
+CREATE INDEX IF NOT EXISTS series_event_id_idx
+ ON series ((body->>'eventId'));
+CREATE INDEX IF NOT EXISTS events_completed_starts_at_idx
+ ON events (starts_at DESC) WHERE state = 'Completed';
+
+-- +mixmaker Down
+DROP INDEX IF EXISTS events_completed_starts_at_idx;
+DROP INDEX IF EXISTS series_event_id_idx;
+DROP TABLE IF EXISTS community_settings;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 645538f..cdb4023 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import App from './App'
import { demoSeries } from './api/demo'
+import { LanguageProvider } from './i18n'
import { activeHeroBanMap } from './series-utils'
describe('Mixmaker frontend', () => {
@@ -12,7 +13,7 @@ describe('Mixmaker frontend', () => {
it('renders the event dashboard', async () => {
window.history.replaceState({}, '', '/events')
- render( )
+ render( )
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
@@ -23,13 +24,13 @@ describe('Mixmaker frontend', () => {
it('renders the Discord login screen', async () => {
window.history.replaceState({}, '', '/login')
- render( )
+ render( )
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
it('locks RSVP and exposes live navigation after the scrim starts', async () => {
window.history.replaceState({}, '', '/events/event-3')
- render( )
+ render( )
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live')
expect(screen.getByRole('heading', { name: 'Balanced teams' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).toBeInTheDocument()
@@ -70,5 +71,42 @@ describe('Mixmaker frontend', () => {
expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /open discord/i })).toHaveAttribute('href', 'https://discord.gg/mixmaker')
+ })
+
+ it('opens linked players and renders profile stats and history', async () => {
+ window.history.replaceState({}, '', '/players/p2')
+ render( )
+ expect(await screen.findByRole('heading', { name: 'Nova', level: 1 })).toBeInTheDocument()
+ expect(screen.getByText('Nova#1002')).toBeInTheDocument()
+ expect(screen.getByRole('region', { name: 'Player statistics' })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Recent matches' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Summer Clash #4/i })).toHaveAttribute('href', '/watch/series-1')
+ })
+
+ it('links player names without wrapping RSVP controls', async () => {
+ window.history.replaceState({}, '', '/events/event-3')
+ render( )
+ const nova = await screen.findAllByRole('link', { name: 'Nova' })
+ expect(nova.some((link) => link.getAttribute('href') === '/players/p2')).toBe(true)
+ expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
+ })
+
+ it('exposes community settings to administrators', async () => {
+ window.history.replaceState({}, '', '/admin')
+ render( )
+ fireEvent.click(await screen.findByRole('tab', { name: 'Community' }))
+ expect(screen.getByRole('heading', { name: 'Community Discord' })).toBeInTheDocument()
+ expect(screen.getByRole('textbox', { name: 'Discord invite URL' })).toHaveValue('https://discord.gg/mixmaker')
+ })
+
+ it('edits player identity', async () => {
+ window.history.replaceState({}, '', '/profile')
+ render( )
+ const nickname = await screen.findByRole('textbox', { name: 'Nickname' })
+ fireEvent.change(nickname, { target: { value: 'Lemintare Prime' } })
+ fireEvent.change(screen.getByRole('textbox', { name: /BattleTag/i }), { target: { value: 'Lemi#2026' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save profile' }))
+ expect(await screen.findByText('Saved')).toBeInTheDocument()
})
})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6a4f811..76ee4c4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import {
- CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
+ Bell, CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, ExternalLink, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap,
} from 'lucide-react'
@@ -11,10 +11,10 @@ import {
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
- type Player, type PlayerRole, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
+ type Player, type PlayerRole, type PublicPlayerProfile, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
- demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
+ demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo'
import { LanguageSelector } from './i18n'
@@ -35,6 +35,7 @@ const protectedRoute = createRoute({
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
+const playerRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/players/$playerId', component: PublicPlayerProfilePage })
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
@@ -52,7 +53,7 @@ const adminRoute = createRoute({
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
- indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
+ indexRoute, profileRoute, playerRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
@@ -118,7 +119,7 @@ function AppShell() {
void router.navigate({ to: '/login' })
},
})
- const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
+ const initials = session.data?.player.displayName.slice(0, 2).toUpperCase() ?? 'MM'
const navItems = isStaff(session.data?.account.role)
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
: mainNavItems
@@ -148,6 +149,9 @@ 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, message }: { retry?: () => void; message?: string }) { return Couldn’t load this view {message || 'Your data is safe. Check your connection and try again.'}
{retry &&
Try again }
}
+function PlayerLink({ player, className }: { player: Pick; className?: string }) {
+ return {player.displayName}
+}
function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
@@ -221,7 +225,7 @@ function EventPage() {
return {event.status !== 'cancelled' && event.workflowState === 'Live' && Open live}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && Match history}{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.mutate(status)}>{status === 'going' ? : status === 'maybe' ? : }{labels[status]} )}
{rsvpMutation.isSuccess && Response saved · Server confirmed just now
}{rsvpMutation.isError && {rsvpMutation.error.message}
}
Attendance {attendanceTotal} responses
{attendanceTotal > 0 && <>{counts.going > 0 && }{counts.maybe > 0 && }{counts.not_going > 0 && }>}
Going {counts.going} Maybe {counts.maybe} No {counts.not_going}
{isStaff(session.data.account.role) && Manage participants }
-
{teams.data && teams.data.length > 0 && Balanced teams Confirmed match rosters are always available here.
{teams.data.map((team) =>
)}
}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]}
)}
}
+ {teams.data && teams.data.length > 0 && Balanced teams Confirmed match rosters are always available here.
{teams.data.map((team) =>
)}
}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()} {labels[registration.status]} )}
}
}
function LiveEntryPage() {
@@ -260,7 +264,9 @@ function LiveEntryPage() {
}
function FAQPage() {
+ const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
return
+
Join the community Discord Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.
{settings.data?.discordInviteUrl &&
Open Discord }
01 How are teams balanced? The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.
02 How does registration work? Choose Going, Maybe, or Can’t go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.
03 What happens before the scrim? An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.
@@ -272,6 +278,44 @@ function FAQPage() {
}
+function PublicPlayerProfilePage() {
+ const { playerId } = playerRoute.useParams()
+ const language = useLanguage()
+ const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
+ const fallback = demoPlayerProfiles.find((player) => player.id === playerId)
+ const profile = useQuery({
+ queryKey: ['player-profile', playerId],
+ queryFn: () => api.playerProfile(playerId),
+ initialData: demoMode ? fallback : undefined,
+ retry: false,
+ })
+ if (profile.isLoading || session.isLoading) return
+ if (profile.isError || session.isError || !profile.data) return void profile.refetch()} />
+ const player: PublicPlayerProfile = profile.data
+ const stats = player.stats
+ const winRate = stats.winRate <= 1 ? Math.round(stats.winRate * 100) : Math.round(stats.winRate)
+ const roles: Array<{ key: PlayerRole; label: string; icon: ReactNode }> = [
+ { key: 'tank', label: 'Tank', icon: },
+ { key: 'damage', label: 'Damage', icon: },
+ { key: 'support', label: 'Support', icon: },
+ ]
+ return
+
Edit profile : undefined} />
+ {player.displayName.slice(0, 2).toUpperCase()}
{player.displayName} {player.battleTag &&
{player.battleTag}
}
{player.isGuest ? 'Guest player' : 'Discord player'}
+
+
Role ranks Current competitive ratings
{roles.map((role) => {role.icon} {role.label} {rankLabel(player.ratings[role.key], language)}
)}
+
+ Series played {stats.seriesPlayed}
+ Series won {stats.seriesWon}
+ Series lost {stats.seriesLost}
+ Win rate {winRate}%
+
+
+ Map record {stats.mapsPlayed} maps played
{stats.mapsWon} Won{stats.mapsLost} Lost{stats.mapsDrawn} Drawn
+ Recent matches Latest completed series
{player.recentMatches.length === 0 ? No completed matches yet.
: {player.recentMatches.map((match) =>
{match.outcome} {match.eventName} {new Date(match.startedAt).toLocaleDateString()}
{match.teamName} {match.scoreFor}: {match.scoreAgainst} {match.opponentTeamName}
)}
}
+
+}
+
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
@@ -279,11 +323,15 @@ function ProfilePage() {
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoRegistrations.map((registration) => registration.player) : undefined })
const [ratings, setRatings] = useState>({ tank: 20, damage: 20, support: 20 })
+ const [displayName, setDisplayName] = useState('')
+ const [battleTag, setBattleTag] = useState('')
const [preferredRoles, setPreferredRoles] = useState([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState([])
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState([])
useEffect(() => {
if (profile.data) {
+ setDisplayName(profile.data.displayName)
+ setBattleTag(profile.data.battleTag ?? '')
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
@@ -298,7 +346,9 @@ function ProfilePage() {
}
}, [players.data])
const update = useMutation({
- mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
+ mutationFn: () => demoMode
+ ? Promise.resolve({ ...profile.data!, displayName: displayName.trim(), battleTag: battleTag.trim() || undefined, ratings: { ...ratings, updatedAt: new Date().toISOString() }, preferredRoles, preferredPlayerIds, avoidedPlayerIds })
+ : api.updateRatings(displayName.trim(), battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
@@ -310,10 +360,10 @@ function ProfilePage() {
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
- return {profile.data.displayName.slice(0, 2).toUpperCase()}
{profile.data.displayName} {profile.data.battleTag}
Connected via Discord
+ return {displayName.slice(0, 2).toUpperCase()}
{displayName || profile.data.displayName} {battleTag &&
{battleTag}
}
Connected via Discord
}
-type AdminTab = 'events' | 'participants' | 'balance' | 'moderators'
+type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community'
function AdminPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const [tab, setTab] = useState('events')
@@ -324,8 +374,23 @@ function AdminPage() {
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
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 && { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => {event.title} )} } { setCreateMode(true); setTab('events') }}> New event > : undefined} />{tabs.map((item) => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : 'Moderators'} )}
{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' && }
+ const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const, 'community' as const] : [])]
+ const standaloneTab = tab === 'moderators' || tab === 'community'
+ return {events.data && events.data.length > 0 && { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => {event.title} )} } { setCreateMode(true); setTab('events') }}> New event > : undefined} />{tabs.map((item) => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : item === 'community' ? 'Community' : 'Moderators'} )}
{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' && }{tab === 'community' && session.data?.account.role === 'admin' && }
+}
+
+function CommunitySettingsPanel() {
+ const client = useQueryClient()
+ const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
+ const [discordInviteUrl, setDiscordInviteUrl] = useState('')
+ useEffect(() => { if (settings.data) setDiscordInviteUrl(settings.data.discordInviteUrl) }, [settings.data])
+ const update = useMutation({
+ mutationFn: () => demoMode ? Promise.resolve({ discordInviteUrl: discordInviteUrl.trim() }) : api.updateCommunitySettings(discordInviteUrl.trim()),
+ onSuccess: (saved) => client.setQueryData(['community-settings'], saved),
+ })
+ if (settings.isLoading) return
+ if (settings.isError) return void settings.refetch()} />
+ return
}
function ModeratorManagement() {
@@ -478,7 +543,7 @@ function Participants({ eventId }: { eventId: string }) {
if (rows.isLoading) return
if (rows.isError || !rows.data) return void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
- return Participant responses Admin overrides are attributed and audited.
{rows.data.filter((r) => r.status === 'going').length} going {rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.displayName} {r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}>}{r.changedBy?.source === 'admin' && <> · set by {r.changedBy.displayName} >}
T · {rankLabel(r.player.ratings.tank, language)} D · {rankLabel(r.player.ratings.damage, language)} S · {rankLabel(r.player.ratings.support, language)}
override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}>Going Maybe Not going remove.mutate(r.player.id)}> )}
{(override.isError || remove.isError) && {override.error?.message ?? remove.error?.message}
}
+ return Participant responses Admin overrides are attributed and audited.
{rows.data.filter((r) => r.status === 'going').length} going {rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}>}{r.changedBy?.source === 'admin' && <> · set by {r.changedBy.displayName} >} T · {rankLabel(r.player.ratings.tank, language)} D · {rankLabel(r.player.ratings.damage, language)} S · {rankLabel(r.player.ratings.support, language)}
override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}>Going Maybe Not going remove.mutate(r.player.id)}> )}
{(override.isError || remove.isError) && {override.error?.message ?? remove.error?.message}
}
}
function BalanceView({ eventId }: { eventId: string }) {
@@ -712,11 +777,11 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
- return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'} {player.displayName} {rankLabel(player.ratings[assignedRole], language)}
)}
{allowCaptainAssignment && Team captain { setCaptain(e.target.value); assign.mutate(e.target.value) }}>Select from roster {team.members.map(({ player }) => {player.displayName} )} {assign.isError && {assign.error.message} } }
+ return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'} {rankLabel(player.ratings[assignedRole], language)} )}
{allowCaptainAssignment && Team captain { setCaptain(e.target.value); assign.mutate(e.target.value) }}>Select from roster {team.members.map(({ player }) => {player.displayName} )} {assign.isError && {assign.error.message} } }
}
function PublicTeamCard({ team }: { team: Team }) {
- return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'} {player.displayName} {player.id === team.captainId && Captain }
)}
+ return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'} {player.id === team.captainId &&
Captain }
)}
}
function LiveSeriesPage() {
@@ -807,11 +872,11 @@ 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
+ const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player
+ const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player
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 && }
}>
+ return <>Team Alpha {series.teamAlpha.name} {alphaCaptain ? <>Captain · > : 'Captain not assigned'} Best of 3 {series.score.alpha}: {series.score.beta} {series.status}
Team Beta {series.teamBeta.name} {betaCaptain ? <>Captain · > : 'Captain not assigned'} {(canRenameAlpha || canRenameBeta) && {canRenameAlpha && }{canRenameBeta && }
}>
}
function TournamentStrip({ series }: { series: Series }) {
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
index 26ddf8e..d208f46 100644
--- a/frontend/src/api/client.test.ts
+++ b/frontend/src/api/client.test.ts
@@ -5,6 +5,7 @@ const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
+ battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
@@ -19,6 +20,7 @@ describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
+ battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
@@ -118,10 +120,38 @@ describe('actual backend routes', () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
- await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
+ await api.updateRatings('Nova Prime', 'Nova#5678', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
+ expect(fetchMock.mock.calls[0][1]?.body).toContain('"displayName":"Nova Prime"')
+ expect(fetchMock.mock.calls[0][1]?.body).toContain('"battleTag":"Nova#5678"')
+ })
+
+ it('loads public profile history and updates community settings', async () => {
+ const publicProfile = {
+ player: {
+ id: player.id,
+ displayName: player.displayName,
+ battleTag: player.battleTag,
+ ratings: player.ratings,
+ isGuest: false,
+ },
+ summary: { seriesPlayed: 3, seriesWon: 2, seriesLost: 1, winRate: 2 / 3, mapsPlayed: 7, mapsWon: 4, mapsLost: 2, mapsDrawn: 1 },
+ recentMatches: [{ seriesId: 'series-1', eventId: 'event-1', eventName: 'Cup', eventDate: '2026-07-18T00:00:00Z', ownTeamId: 'a', ownTeamName: 'A', opponentTeamId: 'b', opponentTeamName: 'B', ownScore: 2, opponentScore: 1, outcome: 'win' }],
+ }
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(new Response(JSON.stringify(publicProfile), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
+ const profile = await api.playerProfile(player.id)
+ expect(profile.stats.mapsDrawn).toBe(1)
+ expect(profile.recentMatches[0].outcome).toBe('win')
+ await api.updateCommunitySettings('https://discord.gg/test')
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/players/player-1')
+ expect(fetchMock.mock.calls[1]).toEqual(['/api/community/settings', expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }),
+ })])
})
it('sends backend RSVP enum values to the player-specific route', async () => {
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 1d95804..d615c71 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -18,6 +18,35 @@ export interface Player {
preferredPlayerIds: string[]
avoidedPlayerIds: string[]
}
+export interface PlayerStats {
+ seriesPlayed: number
+ seriesWon: number
+ seriesLost: number
+ winRate: number
+ mapsPlayed: number
+ mapsWon: number
+ mapsLost: number
+ mapsDrawn: number
+}
+export interface RecentMatch {
+ seriesId: string
+ eventId: string
+ eventName: string
+ startedAt: string
+ teamId: string
+ teamName: string
+ opponentTeamId: string
+ opponentTeamName: string
+ scoreFor: number
+ scoreAgainst: number
+ outcome: 'win' | 'loss'
+}
+export interface PublicPlayerProfile extends Player {
+ isGuest: boolean
+ stats: PlayerStats
+ recentMatches: RecentMatch[]
+}
+export interface CommunitySettings { discordInviteUrl: string }
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player
@@ -154,13 +183,36 @@ export interface RawAccount {
}
export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer {
- id: string; accountId: string; displayName: string; ratings: RawRatings
+ id: string; accountId: string; displayName: string; battleTag?: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
+export interface RawPlayerProfile {
+ player: {
+ id: string
+ displayName: string
+ battleTag: string
+ ratings: RawRatings
+ isGuest: boolean
+ }
+ summary: PlayerStats
+ recentMatches: Array<{
+ seriesId: string
+ eventId: string
+ eventName: string
+ eventDate: string
+ ownTeamId: string
+ ownTeamName: string
+ opponentTeamId: string
+ opponentTeamName: string
+ ownScore: number
+ opponentScore: number
+ outcome: 'win' | 'loss'
+ }>
+}
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
@@ -223,6 +275,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
id: raw.id,
accountId: raw.accountId,
displayName: raw.displayName,
+ battleTag: raw.battleTag || undefined,
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
@@ -529,6 +582,7 @@ export const api = {
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
+ battleTag: string,
ratings: Pick,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
@@ -538,13 +592,50 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({
displayName,
+ battleTag: battleTag.trim(),
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
avoidedPlayerIds,
}),
})),
+ playerProfile: async (playerId: string): Promise => {
+ const profile = await request(`/players/${playerId}`)
+ return {
+ id: profile.player.id,
+ accountId: profile.player.isGuest ? undefined : 'linked',
+ displayName: profile.player.displayName,
+ battleTag: profile.player.battleTag || undefined,
+ ratings: {
+ tank: toRankOrdinal(profile.player.ratings.tank),
+ damage: toRankOrdinal(profile.player.ratings.damage),
+ support: toRankOrdinal(profile.player.ratings.support),
+ updatedAt: '',
+ },
+ preferredRoles: [],
+ preferredPlayerIds: [],
+ avoidedPlayerIds: [],
+ isGuest: profile.player.isGuest,
+ stats: profile.summary,
+ recentMatches: profile.recentMatches.map((match) => ({
+ seriesId: match.seriesId,
+ eventId: match.eventId,
+ eventName: match.eventName,
+ startedAt: match.eventDate,
+ teamId: match.ownTeamId,
+ teamName: match.ownTeamName,
+ opponentTeamId: match.opponentTeamId,
+ opponentTeamName: match.opponentTeamName,
+ scoreFor: match.ownScore,
+ scoreAgainst: match.opponentScore,
+ outcome: match.outcome,
+ })),
+ } satisfies PublicPlayerProfile
+ },
players: async () => (await raw.players()).map(adaptPlayer),
+ communitySettings: () => request('/community/settings'),
+ updateCommunitySettings: (discordInviteUrl: string) =>
+ request('/community/settings', { method: 'PUT', body: JSON.stringify({ discordInviteUrl }) }),
accounts: () => request('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),
diff --git a/frontend/src/api/demo.ts b/frontend/src/api/demo.ts
index 0189518..f81ca40 100644
--- a/frontend/src/api/demo.ts
+++ b/frontend/src/api/demo.ts
@@ -4,6 +4,7 @@ import type {
BracketDraft,
MixEvent,
Player,
+ PublicPlayerProfile,
Registration,
Series,
Session,
@@ -307,3 +308,50 @@ export const demoBracketDraft: BracketDraft = {
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
],
}
+
+export const demoCommunitySettings = {
+ discordInviteUrl: 'https://discord.gg/mixmaker',
+}
+
+export const demoPlayerProfiles: PublicPlayerProfile[] = players.map((player, index) => ({
+ ...player,
+ isGuest: !player.accountId,
+ stats: {
+ seriesPlayed: 18 + index,
+ seriesWon: 11 + (index % 4),
+ seriesLost: 7 + (index % 3),
+ winRate: Math.round(((11 + (index % 4)) / (18 + index)) * 100),
+ mapsPlayed: 42 + index * 2,
+ mapsWon: 25 + index,
+ mapsLost: 14 + index,
+ mapsDrawn: 3,
+ },
+ recentMatches: [
+ {
+ seriesId: 'series-1',
+ eventId: 'event-3',
+ eventName: 'Summer Clash #4',
+ startedAt: daysFromNow(-2, 20),
+ teamId: 'team-alpha',
+ teamName: 'Ember Wolves',
+ opponentTeamId: 'team-beta',
+ opponentTeamName: 'Azure Phantoms',
+ scoreFor: 2,
+ scoreAgainst: 1,
+ outcome: 'win',
+ },
+ {
+ seriesId: 'series-2',
+ eventId: 'event-5',
+ eventName: 'Completed Friday Mix',
+ startedAt: daysFromNow(-7, 20),
+ teamId: 'team-alpha',
+ teamName: 'Ember Wolves',
+ opponentTeamId: 'team-beta',
+ opponentTeamName: 'Neon Foxes',
+ scoreFor: 1,
+ scoreAgainst: 2,
+ outcome: 'loss',
+ },
+ ],
+}))
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index f15e768..646fa2a 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -88,6 +88,29 @@ const translations: Record = {
'Set your competitive ranks': 'Укажите соревновательные ранги',
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
'Connected via Discord': 'Подключено через Discord',
+ 'Edit profile': 'Редактировать профиль',
+ 'Loading player profile…': 'Загрузка профиля игрока…',
+ 'This player profile could not be found.': 'Профиль этого игрока не найден.',
+ 'Community player': 'Игрок сообщества',
+ 'Discord player': 'Игрок Discord',
+ 'Guest player': 'Гостевой игрок',
+ 'Role ranks': 'Ранги по ролям',
+ 'Current competitive ratings': 'Текущие соревновательные рейтинги',
+ 'Competitive history and role ranks.': 'История матчей и ранги по ролям.',
+ 'Player statistics': 'Статистика игрока',
+ 'Series played': 'Серий сыграно',
+ 'Series won': 'Серий выиграно',
+ 'Series lost': 'Серий проиграно',
+ 'Win rate': 'Процент побед',
+ 'Map record': 'Статистика карт',
+ 'Won': 'Победы',
+ 'Lost': 'Поражения',
+ 'Drawn': 'Ничьи',
+ 'Recent matches': 'Недавние матчи',
+ 'Latest completed series': 'Последние завершённые серии',
+ 'No completed matches yet.': 'Завершённых матчей пока нет.',
+ 'win': 'победа',
+ 'loss': 'поражение',
'Role ratings': 'Рейтинги ролей',
'Use the full 1–100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
'Competitive ranks': 'Соревновательные ранги',
@@ -120,6 +143,15 @@ const translations: Record = {
'Event setup': 'Настройки события',
'Participants': 'Участники',
'Balance teams': 'Баланс команд',
+ 'Community': 'Сообщество',
+ 'Community settings': 'Настройки сообщества',
+ 'Manage the links shown to every community member.': 'Управляйте ссылками, доступными всем участникам сообщества.',
+ 'Loading community settings…': 'Загрузка настроек сообщества…',
+ 'Community Discord': 'Discord сообщества',
+ 'This invite is shown in FAQ to signed-in players.': 'Это приглашение показывается авторизованным игрокам в FAQ.',
+ 'Discord invite URL': 'Ссылка-приглашение Discord',
+ 'Leave empty to hide the invite button.': 'Оставьте поле пустым, чтобы скрыть кнопку приглашения.',
+ 'Save settings': 'Сохранить настройки',
'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано',
@@ -148,6 +180,9 @@ const translations: Record = {
'Add participant': 'Добавить участника',
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
'Nickname': 'Ник',
+ 'BattleTag': 'BattleTag',
+ 'Optional': 'Необязательно',
+ 'Nicknames are unique across the community.': 'Ники уникальны в пределах сообщества.',
'Tank rank': 'Ранг танка',
'Damage rank': 'Ранг урона',
'Support rank': 'Ранг поддержки',
@@ -287,6 +322,9 @@ const translations: Record = {
'Rules and help': 'Правила и помощь',
'Frequently asked questions': 'Частые вопросы',
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.',
+ 'Join the community Discord': 'Присоединяйтесь к Discord сообщества',
+ 'Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.': 'В Discord приходят анонсы событий, обновления ролей и уведомления о матчах. Присоединяйтесь к серверу и включите роли бота, уведомления от которых хотите получать.',
+ 'Open Discord': 'Открыть Discord',
'How are teams balanced?': 'Как балансируются команды?',
'The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.': 'Сервер собирает команды 1 танк / 2 урона / 2 поддержки. Главная цель — баланс рангов. Предпочитаемые роли, напарники и до трёх avoid-игроков являются мягкими пожеланиями и не важнее честного корректного состава.',
'How does registration work?': 'Как работает регистрация?',
@@ -312,6 +350,7 @@ function translate(value: string) {
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
[/^(\d+) going$/, (m) => `${m[1]} идут`],
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
+ [/^(\d+) maps played$/, (m) => `${m[1]} карт сыграно`],
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],
diff --git a/frontend/src/index.css b/frontend/src/index.css
index bf4d2dd..b874b79 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -152,6 +152,33 @@ main { min-height: calc(100vh - 72px); }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
.profile-banner h2 { font-size: 21px; }.profile-banner p { color: var(--muted); margin: 3px 0 10px; font-size: 12px; }
+.player-link { color: #f5f7f9; font-weight: 750; transition: color .15s; }
+.player-link:hover { color: var(--orange); text-decoration: underline; text-underline-offset: 3px; }
+.identity-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; padding-bottom: 22px; }
+.identity-fields label,.community-settings label { display: grid; gap: 7px; color: #b6bcc4; font-size: 11px; font-weight: 700; }
+.identity-fields label small { color: var(--muted); font-weight: 500; }
+.identity-fields > p { grid-column: 1/-1; color: var(--muted); font-size: 10px; }
+.player-profile-page { max-width: 1050px; }
+.profile-identity { display: flex; align-items: center; gap: 18px; padding: 22px; margin-bottom: 14px; }
+.profile-identity h2 { font-size: 22px; }.profile-identity p { margin: 4px 0 9px; color: var(--muted); font-size: 12px; }
+.public-profile-grid { display: grid; grid-template-columns: 1fr 1.65fr; gap: 14px; }
+.profile-ranks { padding: 20px; }.profile-ranks .section-title { margin-top: 0; }
+.profile-rank { display: flex; align-items: center; gap: 11px; padding: 12px 0; border-top: 1px solid var(--border); }
+.profile-rank > span:last-child { display: grid; gap: 3px; }.profile-rank small { color: var(--muted); font-size: 9px; text-transform: uppercase; }.profile-rank strong { font-size: 14px; }
+.profile-stat-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 10px; }
+.profile-stat-grid > div { min-height: 120px; padding: 20px; display: flex; flex-direction: column; justify-content: space-between; }
+.profile-stat-grid span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }.profile-stat-grid strong { font-size: 32px; }
+.profile-stat-grid .highlight { border-color: #71401f; background: linear-gradient(145deg,var(--orange-soft),var(--surface)); }.profile-stat-grid .highlight strong { color: var(--orange); }
+.map-record,.recent-matches { margin-top: 14px; padding: 20px; }.map-record .section-title,.recent-matches .section-title { margin-top: 0; }
+.map-record > div:last-child { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
+.map-record > div:last-child span { padding: 14px; display: flex; align-items: baseline; gap: 8px; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); font-size: 10px; }.map-record > div:last-child strong { color: #fff; font-size: 23px; }
+.recent-match { min-height: 72px; padding: 12px 0; display: grid; grid-template-columns: minmax(180px,1fr) minmax(300px,1.4fr) auto; align-items: center; gap: 16px; border-top: 1px solid var(--border); }
+.recent-match > div:first-child { display: flex; align-items: center; gap: 10px; }.recent-match > div:first-child > span { display: grid; gap: 4px; }.recent-match small { color: var(--muted); font-size: 9px; }
+.recent-match-score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; text-align: center; }.recent-match-score span { color: var(--muted); font-size: 10px; }.recent-match-score strong { font-size: 20px; }.recent-match-score i { padding: 0 5px; color: var(--muted); font-style: normal; }
+.recent-match > svg { color: var(--orange); }
+.community-faq { grid-column: 1/-1; min-height: 140px; border-color: #334073; background: linear-gradient(135deg,#171d3c,var(--surface)); }
+.community-faq > span svg { color: #7289da; width: 20px; height: 20px; }.community-faq .button { margin-top: 14px; }
+.community-settings { max-width: 720px; padding: 24px; }.community-settings .section-title { margin-top: 0; }.community-settings .section-title > svg { color: #7289da; width: 25px; height: 25px; }
.ratings-form { padding: 28px; }
.ratings-form > .section-title { margin-top: 0; }
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
@@ -315,7 +342,8 @@ textarea { min-height: 85px; resize: vertical; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
- .profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
+ .profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.identity-fields,.public-profile-grid { grid-template-columns: 1fr; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
+ .profile-stat-grid { grid-template-columns: repeat(2,1fr); }.profile-stat-grid > div { min-height: 100px; padding: 15px; }.map-record > div:last-child { grid-template-columns: 1fr; }.recent-match { grid-template-columns: 1fr auto; }.recent-match-score { grid-column: 1/-1; grid-row: 2; }.recent-match > svg { grid-column: 2; grid-row: 1; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.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%; }
diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md
index 0490683..e99e463 100644
--- a/memory_bank/active_context.md
+++ b/memory_bank/active_context.md
@@ -6,6 +6,8 @@
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает.
+Добавлены профили всех игроков с уникальным nickname, необязательным BattleTag, текущими рангами, статистикой завершённых Bo3/карт и историей последних матчей. Имена в составах и списках ведут в профиль; player-facing UI больше не использует Discord username.
+Admin-only Community settings хранит Discord invite URL. FAQ объясняет Discord-процесс, работу бота и показывает настроенную ссылку; Moderator не может её изменять.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.
@@ -41,6 +43,8 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
- новый микс автоматически анонсируется в настроенном Discord-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании.
- подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла.
- явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса.
+- игроки используют уникальные nickname, могут указать BattleTag и открывать профили друг друга со статистикой и историей матчей;
+- Discord invite для FAQ настраивается только Admin.
## Исходный регламент
diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md
index 3654d77..d878b21 100644
--- a/memory_bank/architecture.md
+++ b/memory_bank/architecture.md
@@ -104,6 +104,12 @@ Application-слой управляет единым versioned workflow собы
Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс.
+### Публичные профили и статистика
+
+Authenticated `GET /players/{id}` возвращает consumer-safe read model без teammate preferences и avoid-списка: nickname, BattleTag, ранги, агрегат серии/карт и десять последних матчей. Агрегатор находится в domain и не зависит от PostgreSQL; adapter загружает завершённые Event, Series и финальные Team rosters. Corrections сворачиваются до эффективного результата, draw учитывается отдельно. Отдельная таблица статистики не нужна для текущего размера сообщества.
+
+Nickname хранится как `Player.DisplayName`, нормализуется и защищён case-insensitive unique index. Discord username остаётся атрибутом Account; player-facing UI использует nickname. BattleTag является необязательным публичным полем.
+
### Календарь и участие
События хранятся в UTC и отображаются в локальном часовом поясе пользователя. REST API отдаёт будущие и прошедшие события и статусы Going/Maybe/NotGoing. Изменения регистрации публикуются через realtime-канал, чтобы Admin сразу видел актуальный список. При изменении статуса за другого игрока API сохраняет actor ID и источник `admin`, а UI показывает, кем сделана отметка.
@@ -118,6 +124,8 @@ Application-слой управляет единым versioned workflow собы
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
+Глобальная Discord invite URL хранится в `community_settings`. Authenticated FAQ читает её через REST; обновление разрешено только Admin и записывается в аудит. Это пользовательская ссылка сообщества и не заменяет секретные Discord bot/guild/channel настройки окружения.
+
После подтверждения roster PostgreSQL-backed worker синхронизирует Discord-роли по desired state. Он создаёт глобальные `Tank` / `Damage` / `Support`, временную роль с текущим `Team.Name` и отдельную `${Team.Name} Captain`; player ID связывается с Discord user ID через Account. Переименование команды и аварийная замена ставят новый reconcile job. Роли определяются по сохранённым Discord ID, поэтому одинаковые названия и повтор jobs не создают логических дубликатов.
Очередь использует `FOR UPDATE SKIP LOCKED`, timeout и retry/backoff для 429/5xx. Частичная недоступность Discord, guest или отсутствующий в guild пользователь не откатывают доменную команду; предупреждение сохраняется в job. Для связанного пользователя вне guild job повторяется раз в пять минут, поэтому после его вступления актуальные назначения появляются без Gateway listener; guest без Discord ID не создаёт бесконечных retries. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID`.
diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md
index a55f95e..9a0fbcf 100644
--- a/memory_bank/domain_model.md
+++ b/memory_bank/domain_model.md
@@ -4,9 +4,11 @@
### Player
-Игрок связан с Discord-аккаунтом и имеет отображаемое имя и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 до Champion 1: Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster, Champion, каждый с дивизионами 5 → 1. Для вычислений значения преобразуются в ordinal 1–40.
+Игрок связан с Discord-аккаунтом либо создан как guest и имеет уникальный без учёта регистра nickname длиной 2–32 символа. Discord username используется только для учётной записи и не является публичным игровым именем. Связанный игрок может дополнительно указать публичный BattleTag в формате `Name#1234`.
-Игрок редактирует ранги самостоятельно; Admin может видеть время последнего изменения. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках.
+Игрок редактирует nickname, BattleTag и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 → Champion 1 и внутри представляется ordinal 1–40. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках.
+
+Авторизованные пользователи могут открыть read-only профиль любого Player, включая guest: текущие ранги, число завершённых Bo3, победы, win rate, карты W/L/D и последние матчи. Статистика вычисляется из завершённых Event/Series и эффективных результатов после corrections. Для существующей модели участие определяется финальным сохранённым составом Event; reserve не получает матч, а emergency substitution относит историю к финальному roster.
### Account
@@ -24,6 +26,10 @@
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
+### CommunitySettings
+
+Singleton-настройки сообщества содержат ссылку-приглашение на Discord, которую FAQ показывает авторизованным игрокам. Читать настройки может любой авторизованный пользователь, изменять ссылку — только Admin, не Moderator; изменение сохраняет автора в audit log. Пустая ссылка скрывает кнопку приглашения.
+
### Discord-роли состава
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md
index 4bc90fb..6d66561 100644
--- a/memory_bank/roadmap.md
+++ b/memory_bank/roadmap.md
@@ -79,7 +79,8 @@
## Этап 5 — удобство сообщества
- пресеты регламентов;
-- статистика игроков и команд;
+- [x] публичные профили игроков, статистика Bo3/карт и история последних матчей;
+- статистика команд;
- повторная жеребьёвка с защитой от одинаковых составов;
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
@@ -87,6 +88,7 @@
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
- [x] отдельные member-list группы для команд и зарегистрированных участников;
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
+- [x] уникальные nickname, BattleTag и настраиваемая Admin ссылка на Discord в FAQ;
- создание и очистка временных командных голосовых каналов;
- импорт данных и резервное копирование.
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index dc9b30d..5be2138 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -10,6 +10,7 @@ security:
tags:
- name: Auth
- name: Players
+ - name: Community
- name: Events
- name: Teams
- name: Series
@@ -110,6 +111,56 @@ paths:
type: array
items:
$ref: "#/components/schemas/Player"
+ /api/players/{playerId}:
+ get:
+ tags: [Players]
+ operationId: getPlayerProfile
+ parameters:
+ - $ref: "#/components/parameters/PlayerId"
+ responses:
+ "200":
+ description: Public-safe player profile, aggregate statistics, and recent completed matches
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PlayerProfile"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ /api/community/settings:
+ get:
+ tags: [Community]
+ operationId: getCommunitySettings
+ responses:
+ "200":
+ description: Community settings
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CommunitySettings"
+ put:
+ tags: [Community]
+ operationId: updateCommunitySettings
+ description: Admin only. Moderators are forbidden.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [discordInviteUrl]
+ properties:
+ discordInviteUrl:
+ type: string
+ description: Empty, or an official discord.gg / discord.com/invite HTTPS URL.
+ responses:
+ "200":
+ description: Updated community settings
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CommunitySettings"
+ "403":
+ $ref: "#/components/responses/Forbidden"
/api/accounts:
get:
tags: [Admin]
@@ -960,7 +1011,7 @@ components:
description: Bronze 5 = 1, Champion 1 = 40
Player:
type: object
- required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
+ required: [id, accountId, displayName, battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
properties:
id:
type: string
@@ -970,6 +1021,12 @@ components:
format: uuid
displayName:
type: string
+ minLength: 2
+ maxLength: 32
+ battleTag:
+ type: string
+ pattern: '^(|[^#\s]{2,20}#[0-9]{3,12})$'
+ description: Optional; an empty string clears the BattleTag.
ratings:
$ref: "#/components/schemas/Ratings"
preferredRoles:
@@ -1000,6 +1057,11 @@ components:
properties:
displayName:
type: string
+ minLength: 2
+ maxLength: 32
+ battleTag:
+ type: string
+ description: Empty string clears the BattleTag.
ratings:
$ref: "#/components/schemas/Ratings"
preferredRoles:
@@ -1017,6 +1079,58 @@ components:
maxItems: 3
items:
type: string
+ PublicPlayer:
+ type: object
+ required: [id, displayName, battleTag, ratings, isGuest]
+ properties:
+ id: { type: string }
+ displayName: { type: string, minLength: 2, maxLength: 32 }
+ battleTag: { type: string }
+ ratings: { $ref: "#/components/schemas/Ratings" }
+ isGuest: { type: boolean }
+ PlayerStatsSummary:
+ type: object
+ required: [seriesPlayed, seriesWon, seriesLost, winRate, mapsPlayed, mapsWon, mapsLost, mapsDrawn]
+ properties:
+ seriesPlayed: { type: integer }
+ seriesWon: { type: integer }
+ seriesLost: { type: integer }
+ winRate: { type: number, minimum: 0, maximum: 1 }
+ mapsPlayed: { type: integer }
+ mapsWon: { type: integer }
+ mapsLost: { type: integer }
+ mapsDrawn: { type: integer }
+ PlayerMatchHistory:
+ type: object
+ required: [seriesId, eventId, eventName, eventDate, ownTeamId, ownTeamName, opponentTeamId, opponentTeamName, ownScore, opponentScore, outcome]
+ properties:
+ seriesId: { type: string }
+ eventId: { type: string }
+ eventName: { type: string }
+ eventDate: { type: string, format: date-time }
+ ownTeamId: { type: string }
+ ownTeamName: { type: string }
+ opponentTeamId: { type: string }
+ opponentTeamName: { type: string }
+ ownScore: { type: integer }
+ opponentScore: { type: integer }
+ outcome: { type: string, enum: [win, loss] }
+ PlayerProfile:
+ type: object
+ required: [player, summary, recentMatches]
+ properties:
+ player: { $ref: "#/components/schemas/PublicPlayer" }
+ summary: { $ref: "#/components/schemas/PlayerStatsSummary" }
+ recentMatches:
+ type: array
+ items: { $ref: "#/components/schemas/PlayerMatchHistory" }
+ CommunitySettings:
+ type: object
+ required: [discordInviteUrl, updatedAt, updatedBy]
+ properties:
+ discordInviteUrl: { type: string }
+ updatedAt: { type: string, format: date-time }
+ updatedBy: { type: string }
EventInput:
type: object
required: [name, startsAt, endsAt, registrationDeadline]