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.Patch("/api/me/player", s.updateProfile)
api.Get("/api/players", s.players)
api.Get("/api/players/{playerID}", s.playerProfile)
api.Get("/api/community/settings", s.communitySettings)
api.Put("/api/community/settings", s.updateCommunitySettings)
api.Get("/api/accounts", s.accounts)
api.Patch("/api/accounts/{accountID}/moderator", s.setModerator)
api.Get("/api/events", s.events)
@@ -246,7 +249,8 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
DisplayName *string `json:"displayName"`
BattleTag *string `json:"battleTag"`
Ratings domain.Ratings `json:"ratings"`
PreferredRoles []domain.Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -256,7 +260,10 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
return
}
id := who(r)
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, application.UpdateOwnProfileInput{
DisplayName: in.DisplayName, BattleTag: in.BattleTag, Ratings: in.Ratings,
PreferredRoles: in.PreferredRoles, PreferredPlayerIDs: in.PreferredPlayerIDs, AvoidedPlayerIDs: in.AvoidedPlayerIDs,
})
respond(w, out, err, http.StatusOK)
}
@@ -265,6 +272,27 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200)
}
func (s *Server) playerProfile(w http.ResponseWriter, r *http.Request) {
out, err := s.service.GetPlayerProfile(r.Context(), chi.URLParam(r, "playerID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) communitySettings(w http.ResponseWriter, r *http.Request) {
out, err := s.service.GetCommunitySettings(r.Context())
respond(w, out, err, http.StatusOK)
}
func (s *Server) updateCommunitySettings(w http.ResponseWriter, r *http.Request) {
var input struct {
DiscordInviteURL string `json:"discordInviteUrl"`
}
if !decode(w, r, &input) {
return
}
out, err := s.service.UpdateCommunitySettings(r.Context(), who(r).account, input.DiscordInviteURL)
respond(w, out, err, http.StatusOK)
}
func (s *Server) accounts(w http.ResponseWriter, r *http.Request) {
out, err := s.service.ListAccounts(r.Context(), who(r).account)
respond(w, out, err, http.StatusOK)

View File

@@ -1,6 +1,18 @@
package httpapi
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
"mixmaker/backend/internal/realtime"
)
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
if !((createEventRequest{}).options().PingEveryone) {
@@ -14,3 +26,67 @@ func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
t.Fatal("explicit false pingEveryone must disable the ping")
}
}
type profileHTTPStore struct {
application.Store
account domain.Account
player domain.Player
}
func (s *profileHTTPStore) AccountBySession(context.Context, string) (domain.Account, domain.Player, error) {
return s.account, s.player, nil
}
func (s *profileHTTPStore) GetPlayer(context.Context, string) (domain.Player, error) {
return s.player, nil
}
func (s *profileHTTPStore) ListEvents(context.Context, time.Time) ([]domain.Event, error) {
return []domain.Event{}, nil
}
func TestPlayerProfileHTTPResponseIsPublicSafe(t *testing.T) {
store := &profileHTTPStore{
account: domain.Account{ID: "account", Role: domain.RolePlayer},
player: domain.Player{
ID: "player", AccountID: "account", DisplayName: "Ana", BattleTag: "Ana#1234",
Ratings: domain.Ratings{Tank: 10, Damage: 20, Support: 30},
PreferredPlayerIDs: []string{"secret-preference"}, AvoidedPlayerIDs: []string{"secret-avoid"},
},
}
hub := realtime.New()
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
request := httptest.NewRequest(http.MethodGet, "/api/players/player", nil)
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
}
var body map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
encoded := response.Body.String()
if strings.Contains(encoded, "preferred") || strings.Contains(encoded, "avoid") || strings.Contains(encoded, "accountId") {
t.Fatalf("profile leaked private fields: %s", encoded)
}
}
func TestModeratorCannotUpdateCommunitySettings(t *testing.T) {
store := &profileHTTPStore{
account: domain.Account{ID: "moderator", Role: domain.RoleModerator},
player: domain.Player{ID: "player", AccountID: "moderator"},
}
hub := realtime.New()
handler := New(application.New(store, hub, nil), store, hub, Config{CookieName: "session"})
request := httptest.NewRequest(http.MethodPut, "/api/community/settings", strings.NewReader(`{"discordInviteUrl":""}`))
request.AddCookie(&http.Cookie{Name: "session", Value: "valid"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("moderator update returned %d: %s", response.Code, response.Body.String())
}
}

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"mixmaker/backend/internal/application"
@@ -62,13 +63,27 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
if err != nil {
return domain.Account{}, domain.Player{}, err
}
p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
p, err := playerByAccount(ctx, tx, account.ID)
if err == nil {
return account, p, tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return domain.Account{}, domain.Player{}, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('mixmaker-player-display-names'))`); err != nil {
return domain.Account{}, domain.Player{}, err
}
displayName, err := uniqueDefaultDisplayName(ctx, tx, account.Username)
if err != nil {
return domain.Account{}, domain.Player{}, err
}
p = domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: displayName, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
VALUES($1,$2,$3,'',$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
RETURNING id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return domain.Account{}, domain.Player{}, err
}
@@ -84,6 +99,51 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
return account, p, tx.Commit(ctx)
}
func playerByAccount(ctx context.Context, tx pgx.Tx, accountID string) (domain.Player, error) {
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err := tx.QueryRow(ctx, `SELECT id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE account_id=$1`, accountID).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return p, err
}
if err = json.Unmarshal(preferredRoles, &p.PreferredRoles); err == nil {
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
}
if err == nil {
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
}
return p, err
}
func uniqueDefaultDisplayName(ctx context.Context, tx pgx.Tx, username string) (string, error) {
baseRunes := []rune(strings.TrimSpace(username))
if len(baseRunes) < 2 {
baseRunes = []rune("Player")
}
if len(baseRunes) > 32 {
baseRunes = baseRunes[:32]
}
base := string(baseRunes)
for number := 1; ; number++ {
candidate := base
if number > 1 {
suffix := fmt.Sprintf("-%d", number)
limit := 32 - len([]rune(suffix))
candidate = string([]rune(base)[:min(len([]rune(base)), limit)]) + suffix
}
var exists bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM players WHERE lower(display_name)=lower($1))`, candidate).Scan(&exists); err != nil {
return "", err
}
if !exists {
return candidate, nil
}
}
}
func (s *Store) ListAccounts(ctx context.Context) ([]domain.Account, error) {
rows, err := s.pool.Query(ctx, `SELECT id,discord_id,username,avatar_url,role,created_at FROM accounts ORDER BY lower(username),id`)
if err != nil {
@@ -124,11 +184,11 @@ func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Acco
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
p.id,p.account_id,p.display_name,p.battle_tag,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
@@ -145,16 +205,35 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe
preferredRoles, _ := json.Marshal(p.PreferredRoles)
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,battle_tag=$3,tank_rating=$4,damage_rating=$5,support_rating=$6,
preferred_roles=$7,preferred_player_ids=$8,avoided_player_ids=$9,updated_at=$10
WHERE id=$1 RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at`,
p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
return p, mapError(err)
}
func (s *Store) GetPlayer(ctx context.Context, id string) (domain.Player, error) {
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err := s.pool.QueryRow(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players WHERE id=$1`, id).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
if err == nil {
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
}
if err == nil {
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
}
return p, mapError(err)
}
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
if err != nil {
return nil, err
@@ -164,7 +243,7 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
for rows.Next() {
var p domain.Player
var preferredRoles, preferredPlayers, avoidedPlayers []byte
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
@@ -189,11 +268,11 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
}
defer tx.Rollback(ctx)
var preferredRoles, preferredPlayers, avoidedPlayers []byte
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,battle_tag,tank_rating,damage_rating,support_rating,created_at,updated_at)
VALUES($1,NULL,$2,$3,$4,$5,$6,$7,$8)
RETURNING id,COALESCE(account_id,''),display_name,battle_tag,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
p.ID, p.DisplayName, p.BattleTag, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.BattleTag, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
@@ -638,6 +717,27 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
return out, mapError(err)
}
func (s *Store) ListSeriesByEvent(ctx context.Context, eventID string) ([]domain.Series, error) {
rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE body->>'eventId'=$1 ORDER BY id`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]domain.Series, 0)
for rows.Next() {
var body []byte
var series domain.Series
if err := rows.Scan(&body); err != nil {
return nil, err
}
if err := json.Unmarshal(body, &series); err != nil {
return nil, err
}
out = append(out, series)
}
return out, rows.Err()
}
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
body, _ := json.Marshal(t)
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
@@ -749,6 +849,20 @@ func (s *Store) GetDraft(ctx context.Context, id string, target any) (string, in
return kind, version, mapError(err)
}
func (s *Store) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
var settings domain.CommunitySettings
err := s.pool.QueryRow(ctx, `SELECT discord_invite_url,updated_at,COALESCE(updated_by,'') FROM community_settings WHERE singleton=true`).
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
return settings, mapError(err)
}
func (s *Store) UpdateCommunitySettings(ctx context.Context, settings domain.CommunitySettings) (domain.CommunitySettings, error) {
err := s.pool.QueryRow(ctx, `UPDATE community_settings SET discord_invite_url=$1,updated_at=$2,updated_by=$3 WHERE singleton=true
RETURNING discord_invite_url,updated_at,updated_by`, settings.DiscordInviteURL, settings.UpdatedAt, settings.UpdatedBy).
Scan(&settings.DiscordInviteURL, &settings.UpdatedAt, &settings.UpdatedBy)
return settings, mapError(err)
}
func (s *Store) AppendAudit(ctx context.Context, actor, action, subject string, payload any) error {
body, _ := json.Marshal(payload)
_, err := s.pool.Exec(ctx, `INSERT INTO audit_log(actor_account_id,action,subject_id,payload) VALUES($1,$2,$3,$4)`, actor, action, subject, body)
@@ -806,5 +920,9 @@ func mapError(err error) error {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ErrNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return fmt.Errorf("%w: value already exists", domain.ErrConflict)
}
return err
}

View File

@@ -63,6 +63,24 @@ func TestMigrationsAndReadiness(t *testing.T) {
t.Fatalf("%s table is missing", table)
}
}
var identityColumns int
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
WHERE table_name='players' AND column_name IN ('display_name','battle_tag')`).Scan(&identityColumns); err != nil {
t.Fatal(err)
}
if identityColumns != 2 {
t.Fatalf("player identity migration is incomplete: found %d columns", identityColumns)
}
var settingsTable, nicknameIndex, seriesEventIndex bool
if err := store.pool.QueryRow(ctx, `SELECT
to_regclass('public.community_settings') IS NOT NULL,
to_regclass('public.players_display_name_lower_uidx') IS NOT NULL,
to_regclass('public.series_event_id_idx') IS NOT NULL`).Scan(&settingsTable, &nicknameIndex, &seriesEventIndex); err != nil {
t.Fatal(err)
}
if !settingsTable || !nicknameIndex || !seriesEventIndex {
t.Fatalf("profile/settings migrations missing: settings=%v nickname=%v series=%v", settingsTable, nicknameIndex, seriesEventIndex)
}
}
func TestDiscordRoleSyncPersistence(t *testing.T) {

View File

@@ -20,6 +20,7 @@ type Store interface {
CreateSession(context.Context, string, string, time.Time) error
DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
GetPlayer(context.Context, string) (domain.Player, error)
ListPlayers(context.Context) ([]domain.Player, error)
EnqueueDiscordRoleSync(context.Context, string, string, int64) error
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
@@ -49,11 +50,14 @@ type Store interface {
GetRuleset(context.Context, string) (domain.Ruleset, error)
SaveSeries(context.Context, domain.Series) (domain.Series, error)
GetSeries(context.Context, string) (domain.Series, error)
ListSeriesByEvent(context.Context, string) ([]domain.Series, error)
SaveTournament(context.Context, domain.Tournament) (domain.Tournament, error)
GetTournament(context.Context, string) (domain.Tournament, error)
GetTournamentByEvent(context.Context, string) (domain.Tournament, error)
SaveDraft(context.Context, string, string, any, int) error
GetDraft(context.Context, string, any) (string, int, error)
GetCommunitySettings(context.Context) (domain.CommunitySettings, error)
UpdateCommunitySettings(context.Context, domain.CommunitySettings) (domain.CommunitySettings, error)
AppendAudit(context.Context, string, string, string, any) error
}
@@ -125,20 +129,35 @@ func (s *Service) SetModerator(ctx context.Context, actor domain.Account, accoun
return account, err
}
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) {
type UpdateOwnProfileInput struct {
DisplayName *string
BattleTag *string
Ratings domain.Ratings
PreferredRoles []domain.Role
PreferredPlayerIDs []string
AvoidedPlayerIDs []string
}
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, input UpdateOwnProfileInput) (domain.Player, error) {
if actor.ID != current.AccountID {
return domain.Player{}, domain.ErrForbidden
}
if err := ratings.Validate(); err != nil {
if err := input.Ratings.Validate(); err != nil {
return domain.Player{}, err
}
if displayName != "" {
current.DisplayName = displayName
if input.DisplayName != nil {
current.DisplayName = *input.DisplayName
}
current.Ratings = ratings
current.PreferredRoles = preferredRoles
current.PreferredPlayerIDs = preferredPlayerIDs
current.AvoidedPlayerIDs = avoidedPlayerIDs
if input.BattleTag != nil {
current.BattleTag = *input.BattleTag
}
if err := current.ValidateIdentity(); err != nil {
return domain.Player{}, err
}
current.Ratings = input.Ratings
current.PreferredRoles = input.PreferredRoles
current.PreferredPlayerIDs = input.PreferredPlayerIDs
current.AvoidedPlayerIDs = input.AvoidedPlayerIDs
current.UpdatedAt = s.Now()
if err := current.ValidatePreferences(); err != nil {
return domain.Player{}, err
@@ -168,6 +187,75 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
return out, err
}
func (s *Service) GetPlayerProfile(ctx context.Context, playerID string) (domain.PlayerProfile, error) {
player, err := s.Store.GetPlayer(ctx, playerID)
if err != nil {
return domain.PlayerProfile{}, err
}
events, err := s.Store.ListEvents(ctx, time.Unix(0, 0).UTC())
if err != nil {
return domain.PlayerProfile{}, err
}
matches := make([]domain.PlayerSeriesContext, 0)
for _, event := range events {
if event.State != domain.Completed {
continue
}
teams, listErr := s.Store.ListTeams(ctx, event.ID)
if listErr != nil {
return domain.PlayerProfile{}, listErr
}
var ownTeam *domain.Team
for index := range teams {
for _, slot := range teams[index].Slots {
if slot.PlayerID == playerID {
ownTeam = &teams[index]
break
}
}
}
if ownTeam == nil {
continue
}
series, listErr := s.Store.ListSeriesByEvent(ctx, event.ID)
if listErr != nil {
return domain.PlayerProfile{}, listErr
}
byID := make(map[string]domain.Team, len(teams))
for _, team := range teams {
byID[team.ID] = team
}
for _, item := range series {
teamA, okA := byID[item.TeamAID]
teamB, okB := byID[item.TeamBID]
if okA && okB {
matches = append(matches, domain.PlayerSeriesContext{Event: event, Series: item, TeamA: teamA, TeamB: teamB})
}
}
}
return domain.BuildPlayerProfile(player, matches, 10), nil
}
func (s *Service) GetCommunitySettings(ctx context.Context) (domain.CommunitySettings, error) {
return s.Store.GetCommunitySettings(ctx)
}
func (s *Service) UpdateCommunitySettings(ctx context.Context, actor domain.Account, inviteURL string) (domain.CommunitySettings, error) {
if !actor.IsAdmin() {
return domain.CommunitySettings{}, domain.ErrForbidden
}
settings := domain.CommunitySettings{DiscordInviteURL: inviteURL, UpdatedAt: s.Now(), UpdatedBy: actor.ID}
if err := settings.Validate(); err != nil {
return domain.CommunitySettings{}, fmt.Errorf("%w: invalid Discord invite URL", err)
}
out, err := s.Store.UpdateCommunitySettings(ctx, settings)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "community.settings_updated", "community", out)
s.Bus.Publish("community-settings", out)
}
return out, err
}
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event, options CreateEventOptions) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
@@ -300,6 +388,9 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
CreatedAt: now,
UpdatedAt: now,
}
if err := player.ValidateIdentity(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
rsvp := domain.RSVP{
EventID: eventID,
PlayerID: player.ID,

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 {
return domain.Event{
Name: "Sunday Mix",

View File

@@ -4,9 +4,11 @@ import (
"errors"
"fmt"
"math/rand"
"regexp"
"slices"
"strings"
"time"
"unicode/utf8"
)
var (
@@ -63,6 +65,7 @@ type Player struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
DisplayName string `json:"displayName"`
BattleTag string `json:"battleTag"`
Ratings Ratings `json:"ratings"`
PreferredRoles []Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
@@ -71,6 +74,21 @@ type Player struct {
UpdatedAt time.Time `json:"updatedAt"`
}
var battleTagPattern = regexp.MustCompile(`^[^#\s]{2,20}#[0-9]{3,12}$`)
func (p *Player) ValidateIdentity() error {
p.DisplayName = strings.TrimSpace(p.DisplayName)
length := utf8.RuneCountInString(p.DisplayName)
if length < 2 || length > 32 {
return fmt.Errorf("%w: display name must contain 2 to 32 characters", ErrInvalid)
}
p.BattleTag = strings.TrimSpace(p.BattleTag)
if p.BattleTag != "" && !battleTagPattern.MatchString(p.BattleTag) {
return fmt.Errorf("%w: BattleTag must have the form Name#digits", ErrInvalid)
}
return nil
}
func (p Player) ValidatePreferences() error {
if len(p.PreferredPlayerIDs) > 3 {
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)

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