Add player profiles and community settings
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:43:48 +03:00
parent 5e0825f43f
commit fa4edc0df5
23 changed files with 1280 additions and 57 deletions

View File

@@ -65,6 +65,9 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
api.Get("/api/me", s.me) api.Get("/api/me", s.me)
api.Patch("/api/me/player", s.updateProfile) api.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players) 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.Get("/api/accounts", s.accounts)
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator) api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
api.Get("/api/events", s.events) 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) { func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var in struct { var in struct {
DisplayName string `json:"displayName"` DisplayName *string `json:"displayName"`
BattleTag *string `json:"battleTag"`
Ratings domain.Ratings `json:"ratings"` Ratings domain.Ratings `json:"ratings"`
PreferredRoles []domain.Role `json:"preferredRoles"` PreferredRoles []domain.Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"` PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -256,7 +260,10 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
return return
} }
id := who(r) 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) 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) 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) { func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
out, err := s.service.ListAccounts(r.Context(), who(r).account) out, err := s.service.ListAccounts(r.Context(), who(r).account)
respond(w, out, err, http.StatusOK) respond(w, out, err, http.StatusOK)

View File

@@ -1,6 +1,18 @@
package httpapi 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) { func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
if !((createEventRequest{}).options().PingEveryone) { if !((createEventRequest{}).options().PingEveryone) {
@@ -14,3 +26,67 @@ func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
t.Fatal("explicit false pingEveryone must disable the ping") 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())
}
}

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"mixmaker/backend/internal/application" "mixmaker/backend/internal/application"
@@ -62,13 +63,27 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
if err != nil { if err != nil {
return domain.Account{}, domain.Player{}, err 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 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) 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 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`, 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). 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 { if err != nil {
return domain.Account{}, domain.Player{}, err 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) 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) { 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`) 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 { if err != nil {
@@ -124,11 +184,11 @@ func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Acco
var p domain.Player var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte 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, 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 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)). 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, 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 { if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles) 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) preferredRoles, _ := json.Marshal(p.PreferredRoles)
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs) preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs) 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, 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=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9 preferred_roles=$7,preferred_player_ids=$8,avoided_player_ids=$9,updated_at=$10
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`, 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.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt). 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.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, &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) return p, mapError(err)
} }
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) { 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`) preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -164,7 +243,7 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
for rows.Next() { for rows.Next() {
var p domain.Player var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte 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 { &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err return nil, err
} }
@@ -189,11 +268,11 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var preferredRoles, preferredPlayers, avoidedPlayers []byte 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) 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) VALUES($1,NULL,$2,$3,$4,$5,$6,$7,$8)
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`, 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.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt). 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.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 { if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles) 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) 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) { func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
body, _ := json.Marshal(t) body, _ := json.Marshal(t)
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4) 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) 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 { func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
body, _ := json.Marshal(payload) 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) _, 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) { if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound 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 return err
} }

View File

@@ -63,6 +63,24 @@ func TestMigrationsAndReadiness(t *testing.T) {
t.Fatalf("%s table is missing", table) 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) { func TestDiscordRoleSyncPersistence(t *testing.T) {

View File

@@ -20,6 +20,7 @@ type Store interface {
CreateSession(context.Context, string, string, time.Time) error CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error) UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
GetPlayer(context.Context, string) (domain.Player, error)
ListPlayers(context.Context) ([]domain.Player, error) ListPlayers(context.Context) ([]domain.Player, error)
EnqueueDiscordRoleSync(context.Context, string, string, int64) error EnqueueDiscordRoleSync(context.Context, string, string, int64) error
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, 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) GetRuleset(context.Context, string) (domain.Ruleset, error)
SaveSeries(context.Context, domain.Series) (domain.Series, error) SaveSeries(context.Context, domain.Series) (domain.Series, error)
GetSeries(context.Context, string) (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) SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
GetTournament(context.Context, string) (domain.Tournament, error) GetTournament(context.Context, string) (domain.Tournament, error)
GetTournamentByEvent(context.Context, string) (domain.Tournament, error) GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
SaveDraft(context.Context, string, string, any, int) error SaveDraft(context.Context, string, string, any, int) error
GetDraft(context.Context, string, any) (string, 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 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 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 { if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden return domain.Player{}, domain.ErrForbidden
} }
if err := ratings.Validate(); err != nil { if err := input.Ratings.Validate(); err != nil {
return domain.Player{}, err return domain.Player{}, err
} }
if displayName != "" { if input.DisplayName != nil {
current.DisplayName = displayName current.DisplayName = *input.DisplayName
} }
current.Ratings = ratings if input.BattleTag != nil {
current.PreferredRoles = preferredRoles current.BattleTag = *input.BattleTag
current.PreferredPlayerIDs = preferredPlayerIDs }
current.AvoidedPlayerIDs = avoidedPlayerIDs 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() current.UpdatedAt = s.Now()
if err := current.ValidatePreferences(); err != nil { if err := current.ValidatePreferences(); err != nil {
return domain.Player{}, err return domain.Player{}, err
@@ -168,6 +187,75 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
return out, err 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) { func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
if !actor.IsStaff() { if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden return domain.Event{}, domain.ErrForbidden
@@ -300,6 +388,9 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
if err := player.ValidateIdentity(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
rsvp := domain.RSVP{ rsvp := domain.RSVP{
EventID: eventID, EventID: eventID,
PlayerID: player.ID, PlayerID: player.ID,

View File

@@ -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 { func validEvent(now time.Time) domain.Event {
return domain.Event{ return domain.Event{
Name: "Sunday Mix", Name: "Sunday Mix",

View File

@@ -4,9 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"regexp"
"slices" "slices"
"strings" "strings"
"time" "time"
"unicode/utf8"
) )
var ( var (
@@ -63,6 +65,7 @@ type Player struct {
ID string `json:"id"` ID string `json:"id"`
AccountID string `json:"accountId"` AccountID string `json:"accountId"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
BattleTag string `json:"battleTag"`
Ratings Ratings `json:"ratings"` Ratings Ratings `json:"ratings"`
PreferredRoles []Role `json:"preferredRoles"` PreferredRoles []Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"` PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -71,6 +74,21 @@ type Player struct {
UpdatedAt time.Time `json:"updatedAt"` 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 { func (p Player) ValidatePreferences() error {
if len(p.PreferredPlayerIDs) > 3 { if len(p.PreferredPlayerIDs) > 3 {
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid) return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import App from './App' import App from './App'
import { demoSeries } from './api/demo' import { demoSeries } from './api/demo'
import { LanguageProvider } from './i18n'
import { activeHeroBanMap } from './series-utils' import { activeHeroBanMap } from './series-utils'
describe('Mixmaker frontend', () => { describe('Mixmaker frontend', () => {
@@ -12,7 +13,7 @@ describe('Mixmaker frontend', () => {
it('renders the event dashboard', async () => { it('renders the event dashboard', async () => {
window.history.replaceState({}, '', '/events') window.history.replaceState({}, '', '/events')
render(<App />) render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument() expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live') 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 () => { it('renders the Discord login screen', async () => {
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
render(<App />) render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord') 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 () => { it('locks RSVP and exposes live navigation after the scrim starts', async () => {
window.history.replaceState({}, '', '/events/event-3') window.history.replaceState({}, '', '/events/event-3')
render(<App />) render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live') 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: 'Balanced teams' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).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(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).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.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(<LanguageProvider><App /></LanguageProvider>)
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(<App />)
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(<LanguageProvider><App /></LanguageProvider>)
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(<LanguageProvider><App /></LanguageProvider>)
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()
}) })
}) })

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import { 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, Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap, UsersRound, X, Zap,
} from 'lucide-react' } from 'lucide-react'
@@ -11,10 +11,10 @@ import {
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent, 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' } from './api/client'
import { import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile, demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession, demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo' } from './api/demo'
import { LanguageSelector } from './i18n' import { LanguageSelector } from './i18n'
@@ -35,6 +35,7 @@ const protectedRoute = createRoute({
}) })
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } }) const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage }) 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 faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage }) const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage }) 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' }) }, beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
}) })
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([ 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 } }) const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } } declare module '@tanstack/react-router' { interface Register { router: typeof router } }
@@ -118,7 +119,7 @@ function AppShell() {
void router.navigate({ to: '/login' }) 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) const navItems = isStaff(session.data?.account.role)
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }] ? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
: mainNavItems : 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 <span className={`badge badge-${tone}`}>{children}</span> } function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> } function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> } function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
function PlayerLink({ player, className }: { player: Pick<Player, 'id' | 'displayName'>; className?: string }) {
return <Link className={`player-link ${className ?? ''}`.trim()} to="/players/$playerId" params={{ playerId: player.id }}>{player.displayName}</Link>
}
function EventsPage() { function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming') const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
@@ -221,7 +225,7 @@ function EventPage() {
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout"> return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section> <section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside> <aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div> </div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><PlayerLink player={registration.player} /><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
} }
function LiveEntryPage() { function LiveEntryPage() {
@@ -260,7 +264,9 @@ function LiveEntryPage() {
} }
function FAQPage() { function FAQPage() {
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid"> return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
<section className="card faq-card community-faq"><span><Bell /></span><div><h2>Join the community Discord</h2><p>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.</p>{settings.data?.discordInviteUrl && <a className="button secondary" href={settings.data.discordInviteUrl} target="_blank" rel="noreferrer">Open Discord <ExternalLink /></a>}</div></section>
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>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.</p></div></section> <section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section> <section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>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.</p></div></section> <section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>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.</p></div></section>
@@ -272,6 +278,44 @@ function FAQPage() {
</div></div> </div></div>
} }
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 <LoadingState label="Loading player profile…" />
if (profile.isError || session.isError || !profile.data) return <ErrorState message="This player profile could not be found." retry={() => 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: <Shield /> },
{ key: 'damage', label: 'Damage', icon: <Zap /> },
{ key: 'support', label: 'Support', icon: <Sparkles /> },
]
return <div className="page player-profile-page">
<PageHeader eyebrow="Player profile" title={player.displayName} description="Competitive history and role ranks." actions={session.data?.player.id === player.id ? <Link className="button secondary" to="/profile">Edit profile</Link> : undefined} />
<section className="profile-identity card"><div className="large-avatar">{player.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{player.displayName}</h2>{player.battleTag && <p>{player.battleTag}</p>}<Badge tone={player.isGuest ? 'neutral' : 'blue'}>{player.isGuest ? 'Guest player' : 'Discord player'}</Badge></div></section>
<div className="public-profile-grid">
<section className="card profile-ranks"><div className="section-title"><div><h2>Role ranks</h2><p>Current competitive ratings</p></div></div>{roles.map((role) => <div className={`profile-rank role-${role.key}`} key={role.key}><span className="role-icon">{role.icon}</span><span><small>{role.label}</small><strong>{rankLabel(player.ratings[role.key], language)}</strong></span></div>)}</section>
<section className="profile-stat-grid" aria-label="Player statistics">
<div className="card"><span>Series played</span><strong>{stats.seriesPlayed}</strong></div>
<div className="card"><span>Series won</span><strong>{stats.seriesWon}</strong></div>
<div className="card"><span>Series lost</span><strong>{stats.seriesLost}</strong></div>
<div className="card highlight"><span>Win rate</span><strong>{winRate}%</strong></div>
</section>
</div>
<section className="card map-record"><div className="section-title"><div><h2>Map record</h2><p>{stats.mapsPlayed} maps played</p></div></div><div><span><strong>{stats.mapsWon}</strong>Won</span><span><strong>{stats.mapsLost}</strong>Lost</span><span><strong>{stats.mapsDrawn}</strong>Drawn</span></div></section>
<section className="card recent-matches"><div className="section-title"><div><h2>Recent matches</h2><p>Latest completed series</p></div></div>{player.recentMatches.length === 0 ? <p className="empty-note">No completed matches yet.</p> : <div>{player.recentMatches.map((match) => <Link to="/watch/$seriesId" params={{ seriesId: match.seriesId }} className="recent-match" key={match.seriesId}><div><Badge tone={match.outcome === 'win' ? 'success' : 'danger'}>{match.outcome}</Badge><span><strong>{match.eventName}</strong><small>{new Date(match.startedAt).toLocaleDateString()}</small></span></div><div className="recent-match-score"><span>{match.teamName}</span><strong>{match.scoreFor}<i>:</i>{match.scoreAgainst}</strong><span>{match.opponentTeamName}</span></div><ChevronRight /></Link>)}</div>}</section>
</div>
}
function ProfilePage() { function ProfilePage() {
const client = useQueryClient() const client = useQueryClient()
const language = useLanguage() const language = useLanguage()
@@ -279,11 +323,15 @@ function ProfilePage() {
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined }) 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 players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoRegistrations.map((registration) => registration.player) : undefined })
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 }) const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [displayName, setDisplayName] = useState('')
const [battleTag, setBattleTag] = useState('')
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([]) const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([]) const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([]) const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
useEffect(() => { useEffect(() => {
if (profile.data) { 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 }) setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles) setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds) setPreferredPlayerIds(profile.data.preferredPlayerIds)
@@ -298,7 +346,9 @@ function ProfilePage() {
} }
}, [players.data]) }, [players.data])
const update = useMutation({ 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) => { onSuccess: (player) => {
client.setQueryData(['profile'], player) client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] }) 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 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 submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId)) const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div> return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{displayName.slice(0, 2).toUpperCase()}</div><div><h2>{displayName || profile.data.displayName}</h2>{battleTag && <p>{battleTag}</p>}<Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><section className="identity-fields"><label>Nickname<input required minLength={2} maxLength={32} autoComplete="nickname" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label><label>BattleTag <small>Optional</small><input maxLength={33} placeholder="Player#1234" value={battleTag} onChange={(event) => setBattleTag(event.target.value)} /></label><p>Nicknames are unique across the community.</p></section><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending || !displayName.trim()}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
} }
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community'
function AdminPage() { function AdminPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity }) const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const [tab, setTab] = useState<AdminTab>('events') const [tab, setTab] = useState<AdminTab>('events')
@@ -324,8 +374,23 @@ function AdminPage() {
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId]) useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" /> if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} /> if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const] : [])] const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const, 'community' as const] : [])]
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={tab !== 'moderators' ? <>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events' && item !== 'moderators'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}</div> const standaloneTab = tab === 'moderators' || tab === 'community'
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : tab === 'community' ? 'Community settings' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : tab === 'community' ? 'Manage the links shown to every community member.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={!standaloneTab ? <>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && !['events', 'moderators', 'community'].includes(item)} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : item === 'community' ? 'Community' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}{tab === 'community' && session.data?.account.role === 'admin' && <CommunitySettingsPanel />}</div>
}
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 <LoadingState label="Loading community settings…" />
if (settings.isError) return <ErrorState message={settings.error.message} retry={() => void settings.refetch()} />
return <form className="card community-settings" onSubmit={(event) => { event.preventDefault(); update.mutate() }}><div className="section-title"><div><h2>Community Discord</h2><p>This invite is shown in FAQ to signed-in players.</p></div><Gamepad2 /></div><label>Discord invite URL<input type="url" inputMode="url" placeholder="https://discord.gg/…" value={discordInviteUrl} onChange={(event) => setDiscordInviteUrl(event.target.value)} /></label><div className="form-footer"><p>{update.isError ? update.error.message : 'Leave empty to hide the invite button.'}</p><button className="button primary" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save settings'}</button></div></form>
} }
function ModeratorManagement() { function ModeratorManagement() {
@@ -478,7 +543,7 @@ function Participants({ eventId }: { eventId: string }) {
if (rows.isLoading) return <LoadingState label="Loading participants…" /> if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} /> if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName])) const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{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' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section> return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><PlayerLink player={r.player} /><span>{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' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
} }
function BalanceView({ eventId }: { eventId: string }) { 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), mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }), onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
}) })
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article> return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><PlayerLink player={player} /><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
} }
function PublicTeamCard({ team }: { team: Team }) { function PublicTeamCard({ team }: { team: Team }) {
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong>{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article> return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><PlayerLink player={player} />{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
} }
function LiveSeriesPage() { function LiveSeriesPage() {
@@ -807,11 +872,11 @@ function LiveSeriesPage() {
function Scoreboard({ series }: { series: Series }) { function Scoreboard({ series }: { series: Series }) {
const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') }) const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity }) 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 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.displayName 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 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) const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
return <><TournamentStrip series={series} /><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</> return <><TournamentStrip series={series} /><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? <>Captain · <PlayerLink player={alphaCaptain} /></> : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? <>Captain · <PlayerLink player={betaCaptain} /></> : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
} }
function TournamentStrip({ series }: { series: Series }) { function TournamentStrip({ series }: { series: Series }) {

View File

@@ -5,6 +5,7 @@ const player: RawPlayer = {
id: 'player-1', id: 'player-1',
accountId: 'account-1', accountId: 'account-1',
displayName: 'Nova', displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 }, ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'], preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'], preferredPlayerIds: ['player-2'],
@@ -19,6 +20,7 @@ describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => { it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({ expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova', displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 }, ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'], preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'], preferredPlayerIds: ['player-2'],
@@ -118,10 +120,38 @@ describe('actual backend routes', () => {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, 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).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('"preferredRoles":["Tank","Damage"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]') 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 () => { it('sends backend RSVP enum values to the player-specific route', async () => {

View File

@@ -18,6 +18,35 @@ export interface Player {
preferredPlayerIds: string[] preferredPlayerIds: string[]
avoidedPlayerIds: 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 { export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole } account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player player: Player
@@ -154,13 +183,36 @@ export interface RawAccount {
} }
export interface RawRatings { tank: number; damage: number; support: number } export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer { 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'> preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[] preferredPlayerIds?: string[]
avoidedPlayerIds?: string[] avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string createdAt: string; updatedAt: string
} }
export interface RawSession { account: RawAccount; player: RawPlayer } 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 { export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
@@ -223,6 +275,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
id: raw.id, id: raw.id,
accountId: raw.accountId, accountId: raw.accountId,
displayName: raw.displayName, displayName: raw.displayName,
battleTag: raw.battleTag || undefined,
ratings, ratings,
preferredRoles, preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [], preferredPlayerIds: raw.preferredPlayerIds ?? [],
@@ -529,6 +582,7 @@ export const api = {
profile: async () => adaptPlayer((await raw.session()).player), profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async ( updateRatings: async (
displayName: string, displayName: string,
battleTag: string,
ratings: Pick<Ratings, PlayerRole>, ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[], preferredRoles: PlayerRole[],
preferredPlayerIds: string[], preferredPlayerIds: string[],
@@ -538,13 +592,50 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
displayName, displayName,
battleTag: battleTag.trim(),
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support }, ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'), preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds, preferredPlayerIds,
avoidedPlayerIds, avoidedPlayerIds,
}), }),
})), })),
playerProfile: async (playerId: string): Promise<PublicPlayerProfile> => {
const profile = await request<RawPlayerProfile>(`/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), players: async () => (await raw.players()).map(adaptPlayer),
communitySettings: () => request<CommunitySettings>('/community/settings'),
updateCommunitySettings: (discordInviteUrl: string) =>
request<CommunitySettings>('/community/settings', { method: 'PUT', body: JSON.stringify({ discordInviteUrl }) }),
accounts: () => request<RawAccount[]>('/accounts'), accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) => setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }), request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),

View File

@@ -4,6 +4,7 @@ import type {
BracketDraft, BracketDraft,
MixEvent, MixEvent,
Player, Player,
PublicPlayerProfile,
Registration, Registration,
Series, Series,
Session, 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' } }, { 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',
},
],
}))

View File

@@ -88,6 +88,29 @@ const translations: Record<string, string> = {
'Set your competitive ranks': 'Укажите соревновательные ранги', 'Set your competitive ranks': 'Укажите соревновательные ранги',
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.', 'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
'Connected via Discord': 'Подключено через Discord', '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': 'Рейтинги ролей', 'Role ratings': 'Рейтинги ролей',
'Use the full 1100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.', 'Use the full 1100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
'Competitive ranks': 'Соревновательные ранги', 'Competitive ranks': 'Соревновательные ранги',
@@ -120,6 +143,15 @@ const translations: Record<string, string> = {
'Event setup': 'Настройки события', 'Event setup': 'Настройки события',
'Participants': 'Участники', 'Participants': 'Участники',
'Balance teams': 'Баланс команд', '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': 'Настройки события', 'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация', 'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано', 'Published': 'Опубликовано',
@@ -148,6 +180,9 @@ const translations: Record<string, string> = {
'Add participant': 'Добавить участника', 'Add participant': 'Добавить участника',
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.', 'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
'Nickname': 'Ник', 'Nickname': 'Ник',
'BattleTag': 'BattleTag',
'Optional': 'Необязательно',
'Nicknames are unique across the community.': 'Ники уникальны в пределах сообщества.',
'Tank rank': 'Ранг танка', 'Tank rank': 'Ранг танка',
'Damage rank': 'Ранг урона', 'Damage rank': 'Ранг урона',
'Support rank': 'Ранг поддержки', 'Support rank': 'Ранг поддержки',
@@ -287,6 +322,9 @@ const translations: Record<string, string> = {
'Rules and help': 'Правила и помощь', 'Rules and help': 'Правила и помощь',
'Frequently asked questions': 'Частые вопросы', 'Frequently asked questions': 'Частые вопросы',
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.', '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?': 'Как балансируются команды?', '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-игроков являются мягкими пожеланиями и не важнее честного корректного состава.', '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?': 'Как работает регистрация?', 'How does registration work?': 'Как работает регистрация?',
@@ -312,6 +350,7 @@ function translate(value: string) {
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`], [/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
[/^(\d+) going$/, (m) => `${m[1]} идут`], [/^(\d+) going$/, (m) => `${m[1]} идут`],
[/^(\d+) responses$/, (m) => `${m[1]} ответов`], [/^(\d+) responses$/, (m) => `${m[1]} ответов`],
[/^(\d+) maps played$/, (m) => `${m[1]} карт сыграно`],
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`], [/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`], [/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`], [/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],

View File

@@ -152,6 +152,33 @@ main { min-height: calc(100vh - 72px); }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; } .profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; } .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; } .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 { padding: 28px; }
.ratings-form > .section-title { margin-top: 0; } .ratings-form > .section-title { margin-top: 0; }
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; } .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; } .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; } .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; } .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); } .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; } .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%; } .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%; }

View File

@@ -6,6 +6,8 @@
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase. Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает. На странице события после выбора баланса публично показываются сохранённые составы команд. В 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 можно откатить на один этап назад. Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена. Добавлен 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 игроки сохраняются как предупреждения и не блокируют микс. Добавлен 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-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании.
- подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла. - подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла.
- явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса. - явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса.
- игроки используют уникальные nickname, могут указать BattleTag и открывать профили друг друга со статистикой и историей матчей;
- Discord invite для FAQ настраивается только Admin.
## Исходный регламент ## Исходный регламент

View File

@@ -104,6 +104,12 @@ Application-слой управляет единым versioned workflow собы
Глобальные роли — Admin, Moderator и Player; Captain назначается staff-пользователем для конкретной команды, а read-only spectator-доступ доступен авторизованным игрокам. Игрок редактирует только собственный профиль, рейтинги и RSVP. Admin и Moderator управляют событиями, участниками, составами и сериями; только Admin может назначать или снимать Moderator. Начальные Admin задаются через `ADMIN_DISCORD_IDS`, чтобы роль нельзя было получить через публичный интерфейс. Глобальные роли — 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 показывает, кем сделана отметка. События хранятся в 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-соединение. Интеграция опциональна и отключена, если 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 не создают логических дубликатов. После подтверждения 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`. Очередь использует `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`.

View File

@@ -4,9 +4,11 @@
### Player ### Player
Игрок связан с Discord-аккаунтом и имеет отображаемое имя и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 до Champion 1: Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster, Champion, каждый с дивизионами 5 → 1. Для вычислений значения преобразуются в ordinal 140. Игрок связан с Discord-аккаунтом либо создан как guest и имеет уникальный без учёта регистра nickname длиной 232 символа. Discord username используется только для учётной записи и не является публичным игровым именем. Связанный игрок может дополнительно указать публичный BattleTag в формате `Name#1234`.
Игрок редактирует ранги самостоятельно; Admin может видеть время последнего изменения. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках. Игрок редактирует nickname, BattleTag и собственный Overwatch-ранг для каждой роли: Tank, Damage, Support. Лестница содержит 40 значений от Bronze 5 → Champion 1 и внутри представляется ordinal 140. Игрок выбирает любое число предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков для avoid. Предпочтения и avoid являются мягкими пожеланиями, а не гарантией состава; один игрок не может одновременно находиться в обоих списках.
Авторизованные пользователи могут открыть read-only профиль любого Player, включая guest: текущие ранги, число завершённых Bo3, победы, win rate, карты W/L/D и последние матчи. Статистика вычисляется из завершённых Event/Series и эффективных результатов после corrections. Для существующей модели участие определяется финальным сохранённым составом Event; reserve не получает матч, а emergency substitution относит историю к финальному roster.
### Account ### Account
@@ -24,6 +26,10 @@
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event. Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
### CommunitySettings
Singleton-настройки сообщества содержат ссылку-приглашение на Discord, которую FAQ показывает авторизованным игрокам. Читать настройки может любой авторизованный пользователь, изменять ссылку — только Admin, не Moderator; изменение сохраняет автора в audit log. Пустая ссылка скрывает кнопку приглашения.
### Discord-роли состава ### Discord-роли состава
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной. Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.

View File

@@ -79,7 +79,8 @@
## Этап 5 — удобство сообщества ## Этап 5 — удобство сообщества
- пресеты регламентов; - пресеты регламентов;
- статистика игроков и команд; - [x] публичные профили игроков, статистика Bo3/карт и история последних матчей;
- статистика команд;
- повторная жеребьёвка с защитой от одинаковых составов; - повторная жеребьёвка с защитой от одинаковых составов;
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию; - [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing; - [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing;
@@ -87,6 +88,7 @@
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
- [x] отдельные member-list группы для команд и зарегистрированных участников; - [x] отдельные member-list группы для команд и зарегистрированных участников;
- [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей; - [x] пятиминутная глобальная сверка и восстановление managed Discord-ролей;
- [x] уникальные nickname, BattleTag и настраиваемая Admin ссылка на Discord в FAQ;
- создание и очистка временных командных голосовых каналов; - создание и очистка временных командных голосовых каналов;
- импорт данных и резервное копирование. - импорт данных и резервное копирование.

View File

@@ -10,6 +10,7 @@ security:
tags: tags:
- name: Auth - name: Auth
- name: Players - name: Players
- name: Community
- name: Events - name: Events
- name: Teams - name: Teams
- name: Series - name: Series
@@ -110,6 +111,56 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/Player" $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: /api/accounts:
get: get:
tags: [Admin] tags: [Admin]
@@ -960,7 +1011,7 @@ components:
description: Bronze 5 = 1, Champion 1 = 40 description: Bronze 5 = 1, Champion 1 = 40
Player: Player:
type: object type: object
required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt] required: [id, accountId, displayName, battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
properties: properties:
id: id:
type: string type: string
@@ -970,6 +1021,12 @@ components:
format: uuid format: uuid
displayName: displayName:
type: string 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: ratings:
$ref: "#/components/schemas/Ratings" $ref: "#/components/schemas/Ratings"
preferredRoles: preferredRoles:
@@ -1000,6 +1057,11 @@ components:
properties: properties:
displayName: displayName:
type: string type: string
minLength: 2
maxLength: 32
battleTag:
type: string
description: Empty string clears the BattleTag.
ratings: ratings:
$ref: "#/components/schemas/Ratings" $ref: "#/components/schemas/Ratings"
preferredRoles: preferredRoles:
@@ -1017,6 +1079,58 @@ components:
maxItems: 3 maxItems: 3
items: items:
type: string 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: EventInput:
type: object type: object
required: [name, startsAt, endsAt, registrationDeadline] required: [name, startsAt, endsAt, registrationDeadline]