Add Discord guild ID configuration and enhance event announcement options
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
This commit is contained in:
2026-07-19 11:34:11 +03:00
parent ae19c03542
commit c3624376b0
29 changed files with 1524 additions and 37 deletions

View File

@@ -22,6 +22,7 @@ ADMIN_DISCORD_IDS=
DISCORD_BOT_TOKEN=
DISCORD_ANNOUNCEMENT_CHANNEL_ID=
DISCORD_ANNOUNCEMENT_LOCALE=ru
DISCORD_GUILD_ID=
# Browser-facing build configuration
VITE_API_BASE_URL=/api

View File

@@ -54,6 +54,20 @@ func main() {
}
}
service := application.New(store, hub, eventAnnouncer)
runCtx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
discordGuildID := os.Getenv("DISCORD_GUILD_ID")
if discordGuildID != "" {
roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{
BotToken: discordBotToken,
GuildID: discordGuildID,
})
if roleErr != nil {
slog.Error("Discord role sync configuration failed", "error", roleErr)
os.Exit(2)
}
go roleWorker.Run(runCtx)
}
cfg := httpapi.Config{
DiscordClientID: required("DISCORD_CLIENT_ID"),
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),
@@ -77,9 +91,7 @@ func main() {
os.Exit(1)
}
}()
stop, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer cancel()
<-stop.Done()
<-runCtx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {

View File

@@ -116,16 +116,16 @@ func New(config Config) (*Announcer, error) {
}, nil
}
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
err := a.announceEventCreated(ctx, event)
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
err := a.announceEventCreated(ctx, event, pingEveryone)
if err != nil {
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
}
return err
}
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
payload, err := json.Marshal(a.createdMessage(event))
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
payload, err := json.Marshal(a.createdMessage(event, pingEveryone))
if err != nil {
return fmt.Errorf("encode Discord message: %w", err)
}
@@ -149,11 +149,10 @@ func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event
return nil
}
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
func (a *Announcer) createdMessage(event domain.Event, pingEveryone bool) messagePayload {
text := announcementTranslations(a.locale)
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
return messagePayload{
Content: "@everyone",
message := messagePayload{
Embeds: []embed{{
Title: truncate(text.titlePrefix+event.Name, 256),
Description: truncate(event.Description, 4096),
@@ -173,8 +172,13 @@ func (a *Announcer) createdMessage(event domain.Event) messagePayload {
URL: eventURL,
}},
}},
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
AllowedMentions: allowedMentions{Parse: []string{}},
}
if pingEveryone {
message.Content = "@everyone"
message.AllowedMentions.Parse = []string{"everyone"}
}
return message
}
func announcementTranslations(locale string) translations {

View File

@@ -47,7 +47,7 @@ func TestAnnounceEventCreated(t *testing.T) {
RegistrationDeadline: start.Add(-2 * time.Hour),
}
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
if err := announcer.AnnounceEventCreated(context.Background(), event, true); err != nil {
t.Fatal(err)
}
if received.Content != "@everyone" {
@@ -75,6 +75,26 @@ func TestAnnounceEventCreated(t *testing.T) {
}
}
func TestCreatedMessageCanOmitEveryonePing(t *testing.T) {
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
})
if err != nil {
t.Fatal(err)
}
event := domain.Event{ID: "event-456", Name: "Quiet mix"}
message := announcer.createdMessage(event, false)
if message.Content != "" || len(message.AllowedMentions.Parse) != 0 {
t.Fatalf("unexpected mention in quiet announcement: content=%q mentions=%+v", message.Content, message.AllowedMentions)
}
if len(message.Embeds) != 1 || len(message.Components) != 1 {
t.Fatalf("quiet announcement must retain embed and registration button: %+v", message)
}
}
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
@@ -91,7 +111,7 @@ func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
t.Fatal(err)
}
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}, true); err == nil {
t.Fatal("expected Discord error")
}
}

View File

@@ -0,0 +1,518 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
type RoleSyncConfig struct {
BotToken string
GuildID string
APIBaseURL string
HTTPClient *http.Client
PollInterval time.Duration
JobTimeout time.Duration
}
type RoleWorker struct {
store application.DiscordRoleStore
manager *RoleManager
pollInterval time.Duration
jobTimeout time.Duration
now func() time.Time
}
type RoleManager struct {
botToken string
guildID string
apiBaseURL string
httpClient *http.Client
}
type discordRoleResponse struct {
ID string `json:"id"`
}
type discordHTTPError struct {
StatusCode int
Status string
Body string
RetryAfter time.Duration
}
func (e *discordHTTPError) Error() string {
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
}
func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*RoleWorker, error) {
if store == nil {
return nil, errors.New("Discord role store is required")
}
manager, err := newRoleManager(config)
if err != nil {
return nil, err
}
pollInterval := config.PollInterval
if pollInterval <= 0 {
pollInterval = 2 * time.Second
}
jobTimeout := config.JobTimeout
if jobTimeout <= 0 {
jobTimeout = 30 * time.Second
}
return &RoleWorker{
store: store,
manager: manager,
pollInterval: pollInterval,
jobTimeout: jobTimeout,
now: func() time.Time { return time.Now().UTC() },
}, nil
}
func newRoleManager(config RoleSyncConfig) (*RoleManager, error) {
if strings.TrimSpace(config.BotToken) == "" {
return nil, errors.New("discord bot token is required for role sync")
}
if strings.TrimSpace(config.GuildID) == "" {
return nil, errors.New("discord guild ID is required for role sync")
}
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &RoleManager{
botToken: config.BotToken,
guildID: config.GuildID,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
}, nil
}
func (w *RoleWorker) Run(ctx context.Context) {
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
slog.Error("Could not seed Discord role sync jobs", "error", err)
}
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
for {
for {
processed, err := w.ProcessNext(ctx)
if err != nil {
slog.Error("Discord role sync worker failed", "error", err)
}
if !processed {
break
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
if errors.Is(err, domain.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout)
defer cancel()
var warnings []string
if job.Action == application.DiscordRoleActionTeardown {
warnings, err = w.teardown(jobCtx, job)
} else {
warnings, err = w.reconcile(jobCtx, job.EventID)
}
if err == nil {
return true, w.store.CompleteDiscordRoleSyncJob(ctx, job.ID, strings.Join(warnings, "; "))
}
delay := retryDelay(job.Attempts, err)
if retryErr := w.store.RetryDiscordRoleSyncJob(ctx, job.ID, err.Error(), w.now().Add(delay)); retryErr != nil {
return true, fmt.Errorf("role sync failed: %v; schedule retry: %w", err, retryErr)
}
return true, nil
}
func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, error) {
current, err := w.store.GetDiscordRoleRoster(ctx, eventID)
if errors.Is(err, domain.ErrNotFound) {
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
}
if err != nil {
return nil, err
}
roles, err := w.store.ListDiscordManagedRoles(ctx, eventID)
if err != nil {
return nil, err
}
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
if err != nil {
return nil, err
}
desired, warnings, err := w.desiredAssignments(ctx, current, roles)
if err != nil {
return warnings, err
}
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
continue
}
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return warnings, nil
}
func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.RosterDraft, existing []application.DiscordManagedRole) ([]application.DiscordManagedRole, error) {
desired := []application.DiscordManagedRole{
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindTank, RoleName: "Tank"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindDamage, RoleName: "Damage"},
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindSupport, RoleName: "Support"},
}
for _, team := range roster.Teams {
teamName := truncate(strings.TrimSpace(team.Name), 100)
desired = append(desired,
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName},
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
)
}
existingByKey := make(map[string]application.DiscordManagedRole, len(existing))
for _, role := range existing {
existingByKey[managedRoleKey(role)] = role
}
desiredKeys := make(map[string]bool, len(desired))
result := make([]application.DiscordManagedRole, 0, len(desired))
for _, role := range desired {
key := managedRoleKey(role)
desiredKeys[key] = true
if saved, ok := existingByKey[key]; ok {
role.DiscordRoleID = saved.DiscordRoleID
if saved.RoleName != role.RoleName {
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); err != nil {
return nil, err
}
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
return nil, err
}
}
} else {
roleID, err := w.manager.CreateRole(ctx, role.RoleName)
if err != nil {
return nil, err
}
role.DiscordRoleID = roleID
if err = w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
_ = w.manager.DeleteRole(ctx, roleID)
return nil, err
}
}
result = append(result, role)
}
for _, role := range existing {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || desiredKeys[managedRoleKey(role)] {
continue
}
if err := w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
}
return result, nil
}
func (w *RoleWorker) desiredAssignments(ctx context.Context, current application.DiscordRoleRoster, roles []application.DiscordManagedRole) (map[string]map[string]bool, []string, error) {
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole, len(roles))
for _, role := range roles {
byKey[managedRoleKey(role)] = role
desired[role.DiscordRoleID] = make(map[string]bool)
}
warnings := appendRosterAssignments(desired, byKey, current, true)
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, warnings, err
}
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
return desired, uniqueStrings(warnings), nil
}
func appendRosterAssignments(desired map[string]map[string]bool, roles map[string]application.DiscordManagedRole, item application.DiscordRoleRoster, includeEventRoles bool) []string {
warnings := make([]string, 0)
for _, team := range item.Roster.Teams {
for _, slot := range team.Slots {
if slot.PlayerID == "" {
continue
}
discordID := item.PlayerDiscordIDs[slot.PlayerID]
if discordID == "" {
warnings = append(warnings, "player "+slot.PlayerID+" has no linked Discord account")
continue
}
globalRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeGlobal, Kind: roleKind(slot.Role)})]
if globalRole.DiscordRoleID != "" {
desired[globalRole.DiscordRoleID][discordID] = true
}
if !includeEventRoles {
continue
}
teamRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam})]
if teamRole.DiscordRoleID != "" {
desired[teamRole.DiscordRoleID][discordID] = true
}
if slot.PlayerID == team.CaptainPlayerID {
captainRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain})]
if captainRole.DiscordRoleID != "" {
desired[captainRole.DiscordRoleID][discordID] = true
}
}
}
}
return warnings
}
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
if err != nil {
return nil, err
}
currentSet := make(map[string]bool, len(current))
for _, userID := range current {
currentSet[userID] = true
}
warnings := make([]string, 0)
for userID := range desired {
if currentSet[userID] {
continue
}
if err = w.manager.AddMemberRole(ctx, userID, roleID); err != nil {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
continue
}
return warnings, err
}
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
for userID := range currentSet {
if desired[userID] {
continue
}
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
return warnings, err
}
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
return warnings, err
}
}
return warnings, nil
}
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
roles, err := w.store.ListDiscordManagedRoles(ctx, job.EventID)
if err != nil {
return nil, err
}
deleted := make(map[string]bool)
for _, role := range roles {
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != job.EventID {
continue
}
if err = w.deleteManagedRole(ctx, role); err != nil {
return nil, err
}
deleted[role.DiscordRoleID] = true
}
for _, roleID := range job.RoleSnapshot {
if deleted[roleID] {
continue
}
if err = w.manager.DeleteRole(ctx, roleID); err != nil && !isDiscordNotFound(err) {
return nil, err
}
}
globalRoles := make([]application.DiscordManagedRole, 0)
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeGlobal {
globalRoles = append(globalRoles, role)
}
}
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
if err != nil {
return nil, err
}
desired := make(map[string]map[string]bool)
byKey := make(map[string]application.DiscordManagedRole)
for _, role := range globalRoles {
desired[role.DiscordRoleID] = make(map[string]bool)
byKey[managedRoleKey(role)] = role
}
warnings := make([]string, 0)
for _, roster := range active {
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
}
for _, role := range globalRoles {
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
warnings = append(warnings, roleWarnings...)
if syncErr != nil {
return warnings, syncErr
}
}
return uniqueStrings(warnings), nil
}
func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
if err := w.manager.DeleteRole(ctx, role.DiscordRoleID); err != nil && !isDiscordNotFound(err) {
return err
}
return w.store.DeleteDiscordManagedRole(ctx, role)
}
func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) {
var response discordRoleResponse
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
"name": name, "permissions": "0", "hoist": false, "mentionable": false,
}, &response); err != nil {
return "", err
}
if response.ID == "" {
return "", errors.New("Discord create role response has no ID")
}
return response.ID, nil
}
func (m *RoleManager) RenameRole(ctx context.Context, roleID, name string) error {
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, nil)
}
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
}
func (m *RoleManager) AddMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodPut, m.guildPath(path), nil, nil)
}
func (m *RoleManager) RemoveMemberRole(ctx context.Context, userID, roleID string) error {
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
return m.request(ctx, http.MethodDelete, m.guildPath(path), nil, nil)
}
func (m *RoleManager) guildPath(path string) string {
return m.apiBaseURL + "/guilds/" + url.PathEscape(m.guildID) + path
}
func (m *RoleManager) request(ctx context.Context, method, endpoint string, payload any, result any) error {
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
request.Header.Set("Authorization", "Bot "+m.botToken)
if payload != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := m.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
httpErr := &discordHTTPError{StatusCode: response.StatusCode, Status: response.Status, Body: strings.TrimSpace(string(responseBody))}
if seconds, parseErr := strconv.ParseFloat(response.Header.Get("Retry-After"), 64); parseErr == nil {
httpErr.RetryAfter = time.Duration(seconds * float64(time.Second))
}
return httpErr
}
if result != nil {
return json.NewDecoder(response.Body).Decode(result)
}
return nil
}
func retryDelay(attempts int, err error) time.Duration {
var httpErr *discordHTTPError
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
return httpErr.RetryAfter
}
if attempts < 1 {
attempts = 1
}
if attempts > 6 {
attempts = 6
}
return time.Duration(1<<uint(attempts-1)) * time.Second
}
func managedRoleKey(role application.DiscordManagedRole) string {
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
}
func roleKind(role domain.Role) string {
switch role {
case domain.Tank:
return application.DiscordRoleKindTank
case domain.Damage:
return application.DiscordRoleKindDamage
case domain.Support:
return application.DiscordRoleKindSupport
default:
return ""
}
}
func isDiscordNotFound(err error) bool {
var httpErr *discordHTTPError
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,384 @@
package discord
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
type roleStoreFake struct {
mu sync.Mutex
jobs []application.DiscordRoleSyncJob
roles map[string]application.DiscordManagedRole
assignments map[string]map[string]bool
rosters map[string]application.DiscordRoleRoster
active []application.DiscordRoleRoster
warnings []string
}
func newRoleStoreFake() *roleStoreFake {
return &roleStoreFake{
roles: make(map[string]application.DiscordManagedRole),
assignments: make(map[string]map[string]bool),
rosters: make(map[string]application.DiscordRoleRoster),
}
}
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.jobs) == 0 {
return application.DiscordRoleSyncJob{}, domain.ErrNotFound
}
job := s.jobs[0]
s.jobs = s.jobs[1:]
job.Attempts++
return job, nil
}
func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, warning string) error {
s.warnings = append(s.warnings, warning)
return nil
}
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
s.warnings = append(s.warnings, message)
return nil
}
func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID string) ([]application.DiscordManagedRole, error) {
out := make([]application.DiscordManagedRole, 0)
for _, role := range s.roles {
if role.Scope == application.DiscordRoleScopeGlobal || role.EventID == eventID {
out = append(out, role)
}
}
return out, nil
}
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
s.roles[managedRoleKey(role)] = role
return nil
}
func (s *roleStoreFake) DeleteDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
delete(s.assignments, role.DiscordRoleID)
delete(s.roles, managedRoleKey(role))
return nil
}
func (s *roleStoreFake) ListDiscordRoleAssignments(_ context.Context, roleID string) ([]string, error) {
out := make([]string, 0)
for userID := range s.assignments[roleID] {
out = append(out, userID)
}
return out, nil
}
func (s *roleStoreFake) UpsertDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
if s.assignments[roleID] == nil {
s.assignments[roleID] = make(map[string]bool)
}
s.assignments[roleID][userID] = true
return nil
}
func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
delete(s.assignments[roleID], userID)
return nil
}
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
roster, ok := s.rosters[eventID]
if !ok {
return roster, domain.ErrNotFound
}
return roster, nil
}
func (s *roleStoreFake) ListActiveDiscordRoleRosters(context.Context) ([]application.DiscordRoleRoster, error) {
return s.active, nil
}
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
var mu sync.Mutex
roleNames := make(map[string]string)
methods := make([]string, 0)
nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
mu.Lock()
defer mu.Unlock()
methods = append(methods, request.Method+" "+request.URL.Path)
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
var payload map[string]any
_ = json.NewDecoder(request.Body).Decode(&payload)
nextID++
id := "role-" + string(rune('0'+nextID))
roleNames[id], _ = payload["name"].(string)
response.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(response).Encode(map[string]string{"id": id})
return
}
if request.Method == http.MethodPatch {
var payload map[string]string
_ = json.NewDecoder(request.Body).Decode(&payload)
parts := strings.Split(request.URL.Path, "/")
roleNames[parts[len(parts)-1]] = payload["name"]
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
item := testDiscordRoster("Alpha")
store.rosters["event-1"] = item
store.active = []application.DiscordRoleRoster{item}
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
worker, err := NewRoleWorker(store, RoleSyncConfig{
BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if processed, err := worker.ProcessNext(context.Background()); err != nil || !processed {
t.Fatalf("first reconcile: processed=%v err=%v", processed, err)
}
names := make([]string, 0, len(roleNames))
for _, name := range roleNames {
names = append(names, name)
}
sort.Strings(names)
expectedNames := []string{"Alpha", "Alpha Captain", "Damage", "Support", "Tank"}
if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
t.Fatalf("unexpected role names: %v", names)
}
if got := assignmentCount(store.assignments); got != 11 {
t.Fatalf("expected 11 managed assignments, got %d", got)
}
firstRequestCount := len(methods)
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if len(methods) != firstRequestCount {
t.Fatalf("idempotent reconcile made %d extra Discord calls", len(methods)-firstRequestCount)
}
renamed := testDiscordRoster("Night Owls")
store.rosters["event-1"] = renamed
store.active = []application.DiscordRoleRoster{renamed}
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
patches := 0
for _, method := range methods[firstRequestCount:] {
if strings.HasPrefix(method, http.MethodPatch+" ") {
patches++
}
}
if patches != 2 {
t.Fatalf("expected team and captain role rename, got %d PATCH calls", patches)
}
roleChanged := testDiscordRoster("Night Owls")
roleChanged.Roster.Teams[0].Slots[0].Role = domain.Damage
roleChanged.Roster.Teams[0].Slots[1].Role = domain.Tank
store.rosters["event-1"] = roleChanged
store.active = []application.DiscordRoleRoster{roleChanged}
store.jobs = []application.DiscordRoleSyncJob{{ID: 4, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
tankRoleID := managedRoleID(store.roles, application.DiscordRoleKindTank)
damageRoleID := managedRoleID(store.roles, application.DiscordRoleKindDamage)
if !store.assignments[tankRoleID]["user-2"] || store.assignments[tankRoleID]["user-1"] {
t.Fatalf("tank assignment did not follow changed slot: %v", store.assignments[tankRoleID])
}
if !store.assignments[damageRoleID]["user-1"] || store.assignments[damageRoleID]["user-2"] {
t.Fatalf("damage assignment did not follow changed slot: %v", store.assignments[damageRoleID])
}
}
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
return
}
if request.Method == http.MethodPut && strings.Contains(request.URL.Path, "/members/missing/") {
http.Error(response, `{"message":"Unknown Member"}`, http.StatusNotFound)
return
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
item := testDiscordRoster("Alpha")
item.PlayerDiscordIDs["player-1"] = "missing"
store.rosters["event-1"] = item
store.active = []application.DiscordRoleRoster{item}
store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
worker, err := NewRoleWorker(store, RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
t.Fatalf("missing member warning was not stored: %v", store.warnings)
}
}
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
nextID := 0
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
nextID++
_ = json.NewEncoder(response).Encode(map[string]string{"id": "role-" + string(rune('0'+nextID))})
return
}
response.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
store := newRoleStoreFake()
first := testDiscordRoster("Alpha")
second := testDiscordRoster("Bravo")
second.Roster.EventID = "event-2"
second.Roster.Teams[0].EventID = "event-2"
second.Roster.Teams[0].ID = "team-2"
second.PlayerDiscordIDs = make(map[string]string)
for index := range second.Roster.Teams[0].Slots {
playerID := "other-" + string(rune('1'+index))
second.Roster.Teams[0].Slots[index].PlayerID = playerID
second.PlayerDiscordIDs[playerID] = "other-user-" + string(rune('1'+index))
}
second.Roster.Teams[0].CaptainPlayerID = second.Roster.Teams[0].Slots[0].PlayerID
store.rosters["event-1"], store.rosters["event-2"] = first, second
store.active = []application.DiscordRoleRoster{first, second}
store.jobs = []application.DiscordRoleSyncJob{
{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile},
{ID: 2, EventID: "event-2", Action: application.DiscordRoleActionReconcile},
}
worker, err := NewRoleWorker(store, RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
store.active = []application.DiscordRoleRoster{second}
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionTeardown}}
if _, err = worker.ProcessNext(context.Background()); err != nil {
t.Fatal(err)
}
globalAssignments := 0
for _, role := range store.roles {
if role.Scope == application.DiscordRoleScopeEvent && role.EventID == "event-1" {
t.Fatalf("event-1 role was not deleted: %+v", role)
}
if role.Scope == application.DiscordRoleScopeGlobal {
globalAssignments += len(store.assignments[role.DiscordRoleID])
for userID := range store.assignments[role.DiscordRoleID] {
if !strings.HasPrefix(userID, "other-user-") {
t.Fatalf("stale global assignment remained after teardown: %s", userID)
}
}
}
}
if globalAssignments != 5 {
t.Fatalf("expected five global assignments for the other event, got %d", globalAssignments)
}
}
func TestRoleManagerReturnsRateLimit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Retry-After", "2")
http.Error(response, `{"message":"rate limited"}`, http.StatusTooManyRequests)
}))
defer server.Close()
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
err = manager.AddMemberRole(context.Background(), "user", "role")
var httpErr *discordHTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusTooManyRequests || retryDelay(1, err) != 2*time.Second {
t.Fatalf("unexpected rate limit error: %#v", err)
}
}
func TestRoleManagerReturnsServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"temporary failure"}`, http.StatusBadGateway)
}))
defer server.Close()
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
err = manager.DeleteRole(context.Background(), "role")
var httpErr *discordHTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadGateway {
t.Fatalf("unexpected server error: %#v", err)
}
}
func testDiscordRoster(name string) application.DiscordRoleRoster {
slots := []domain.Slot{
{PlayerID: "player-1", Role: domain.Tank},
{PlayerID: "player-2", Role: domain.Damage},
{PlayerID: "player-3", Role: domain.Damage},
{PlayerID: "player-4", Role: domain.Support},
{PlayerID: "player-5", Role: domain.Support},
}
discordIDs := make(map[string]string)
for index := 1; index <= 5; index++ {
discordIDs["player-"+string(rune('0'+index))] = "user-" + string(rune('0'+index))
}
return application.DiscordRoleRoster{
Roster: domain.RosterDraft{
EventID: "event-1",
Teams: []domain.Team{{
ID: "team-1", EventID: "event-1", Name: name, CaptainPlayerID: "player-1", Slots: slots,
}},
Confirmed: true,
},
PlayerDiscordIDs: discordIDs,
}
}
func assignmentCount(assignments map[string]map[string]bool) int {
total := 0
for _, users := range assignments {
total += len(users)
}
return total
}
func managedRoleID(roles map[string]application.DiscordManagedRole, kind string) string {
for _, role := range roles {
if role.Scope == application.DiscordRoleScopeGlobal && role.Kind == kind {
return role.DiscordRoleID
}
}
return ""
}

View File

@@ -292,12 +292,31 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200)
}
type createEventRequest struct {
Name string `json:"name"`
Description string `json:"description"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
RegistrationDeadline time.Time `json:"registrationDeadline"`
PingEveryone *bool `json:"pingEveryone"`
}
func (in createEventRequest) options() application.CreateEventOptions {
return application.CreateEventOptions{PingEveryone: in.PingEveryone == nil || *in.PingEveryone}
}
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
var in domain.Event
var in createEventRequest
if !decode(w, r, &in) {
return
}
out, err := s.service.CreateEvent(r.Context(), who(r).account, in)
out, err := s.service.CreateEvent(r.Context(), who(r).account, domain.Event{
Name: in.Name,
Description: in.Description,
StartsAt: in.StartsAt,
EndsAt: in.EndsAt,
RegistrationDeadline: in.RegistrationDeadline,
}, in.options())
respond(w, out, err, http.StatusCreated)
}

View File

@@ -0,0 +1,16 @@
package httpapi
import "testing"
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
if !((createEventRequest{}).options().PingEveryone) {
t.Fatal("legacy create requests without pingEveryone must keep @everyone enabled")
}
}
func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
disabled := false
if (createEventRequest{PingEveryone: &disabled}).options().PingEveryone {
t.Fatal("explicit false pingEveryone must disable the ping")
}
}

View File

@@ -0,0 +1,215 @@
package postgres
import (
"context"
"encoding/json"
"time"
"mixmaker/backend/internal/application"
"mixmaker/backend/internal/domain"
)
func (s *Store) EnqueueDiscordRoleSync(ctx context.Context, eventID, action string, generation int64) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation,role_snapshot)
SELECT $1,$2,$3,COALESCE(jsonb_agg(discord_role_id) FILTER (WHERE discord_role_id IS NOT NULL),'[]'::jsonb)
FROM discord_managed_roles WHERE scope='event' AND event_id=$1
ON CONFLICT(event_id,action,generation) DO UPDATE
SET status='pending',available_at=now(),updated_at=now(),completed_at=NULL`,
eventID, action, generation)
return err
}
func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation)
SELECT e.id,'reconcile',e.version
FROM events e
JOIN event_rosters er ON er.event_id=e.id
WHERE e.state IN ('RostersConfirmed','BracketDraft','Live')
AND COALESCE((er.body->>'confirmed')::boolean,false)=true
ON CONFLICT(event_id,action,generation) DO NOTHING`)
return err
}
func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) {
var job application.DiscordRoleSyncJob
var snapshot []byte
err := s.pool.QueryRow(ctx, `WITH candidate AS (
SELECT id FROM discord_role_sync_jobs
WHERE (status='pending' AND available_at<=now())
OR (status='processing' AND updated_at<now()-interval '5 minutes')
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE discord_role_sync_jobs j
SET status='processing',attempts=j.attempts+1,updated_at=now()
FROM candidate
WHERE j.id=candidate.id
RETURNING j.id,j.event_id,j.action,j.generation,j.role_snapshot,j.attempts`).
Scan(&job.ID, &job.EventID, &job.Action, &job.Generation, &snapshot, &job.Attempts)
if err != nil {
return job, mapError(err)
}
if err = json.Unmarshal(snapshot, &job.RoleSnapshot); err != nil {
return job, err
}
return job, nil
}
func (s *Store) CompleteDiscordRoleSyncJob(ctx context.Context, jobID int64, warning string) error {
_, err := s.pool.Exec(ctx, `UPDATE discord_role_sync_jobs
SET status='completed',last_error=$2,completed_at=now(),updated_at=now()
WHERE id=$1`, jobID, warning)
return err
}
func (s *Store) RetryDiscordRoleSyncJob(ctx context.Context, jobID int64, message string, availableAt time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE discord_role_sync_jobs
SET status='pending',last_error=$2,available_at=$3,updated_at=now()
WHERE id=$1`, jobID, message, availableAt)
return err
}
func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]application.DiscordManagedRole, error) {
rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name
FROM discord_managed_roles
WHERE scope='global' OR event_id=$1
ORDER BY scope,event_id,team_id,kind`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
roles := make([]application.DiscordManagedRole, 0)
for rows.Next() {
var role application.DiscordManagedRole
if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName); err != nil {
return nil, err
}
roles = append(roles, role)
}
return roles, rows.Err()
}
func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name)
VALUES($1,$2,$3,$4,$5,$6)
ON CONFLICT(scope,event_id,team_id,kind) DO UPDATE
SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,updated_at=now()`,
role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName)
return err
}
func (s *Store) DeleteDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
_, err := s.pool.Exec(ctx, `DELETE FROM discord_managed_roles
WHERE scope=$1 AND event_id=$2 AND team_id=$3 AND kind=$4`,
role.Scope, role.EventID, role.TeamID, role.Kind)
return err
}
func (s *Store) ListDiscordRoleAssignments(ctx context.Context, discordRoleID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT discord_user_id FROM discord_role_assignments
WHERE discord_role_id=$1 ORDER BY discord_user_id`, discordRoleID)
if err != nil {
return nil, err
}
defer rows.Close()
userIDs := make([]string, 0)
for rows.Next() {
var userID string
if err = rows.Scan(&userID); err != nil {
return nil, err
}
userIDs = append(userIDs, userID)
}
return userIDs, rows.Err()
}
func (s *Store) UpsertDiscordRoleAssignment(ctx context.Context, discordRoleID, discordUserID string) error {
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_assignments(discord_role_id,discord_user_id)
VALUES($1,$2) ON CONFLICT(discord_role_id,discord_user_id) DO UPDATE SET updated_at=now()`,
discordRoleID, discordUserID)
return err
}
func (s *Store) DeleteDiscordRoleAssignment(ctx context.Context, discordRoleID, discordUserID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM discord_role_assignments
WHERE discord_role_id=$1 AND discord_user_id=$2`, discordRoleID, discordUserID)
return err
}
func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) {
roster, err := s.GetRoster(ctx, eventID)
if err != nil {
return application.DiscordRoleRoster{}, err
}
return s.discordRoleRoster(ctx, roster)
}
func (s *Store) ListActiveDiscordRoleRosters(ctx context.Context) ([]application.DiscordRoleRoster, error) {
rows, err := s.pool.Query(ctx, `SELECT er.body
FROM event_rosters er
JOIN events e ON e.id=er.event_id
WHERE e.state IN ('RostersConfirmed','BracketDraft','Live')
AND COALESCE((er.body->>'confirmed')::boolean,false)=true
ORDER BY er.event_id`)
if err != nil {
return nil, err
}
defer rows.Close()
rosters := make([]domain.RosterDraft, 0)
for rows.Next() {
var body []byte
var roster domain.RosterDraft
if err = rows.Scan(&body); err != nil {
return nil, err
}
if err = json.Unmarshal(body, &roster); err != nil {
return nil, err
}
rosters = append(rosters, roster)
}
if err = rows.Err(); err != nil {
return nil, err
}
out := make([]application.DiscordRoleRoster, 0, len(rosters))
for _, roster := range rosters {
item, rosterErr := s.discordRoleRoster(ctx, roster)
if rosterErr != nil {
return nil, rosterErr
}
out = append(out, item)
}
return out, nil
}
func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) {
playerIDs := make([]string, 0)
for _, team := range roster.Teams {
for _, slot := range team.Slots {
if slot.PlayerID != "" {
playerIDs = append(playerIDs, slot.PlayerID)
}
}
}
discordIDs := make(map[string]string)
if len(playerIDs) > 0 {
rows, err := s.pool.Query(ctx, `SELECT p.id,a.discord_id
FROM players p JOIN accounts a ON a.id=p.account_id
WHERE p.id=ANY($1)`, playerIDs)
if err != nil {
return application.DiscordRoleRoster{}, err
}
defer rows.Close()
for rows.Next() {
var playerID, discordID string
if err = rows.Scan(&playerID, &discordID); err != nil {
return application.DiscordRoleRoster{}, err
}
discordIDs[playerID] = discordID
}
if err = rows.Err(); err != nil {
return application.DiscordRoleRoster{}, err
}
}
return application.DiscordRoleRoster{Roster: roster, PlayerDiscordIDs: discordIDs}, nil
}

View File

@@ -312,6 +312,12 @@ func (s *Store) DeleteEvent(ctx context.Context, eventID string) error {
if _, err = tx.Exec(ctx, `DELETE FROM series WHERE body->>'eventId'=$1`, eventID); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation,role_snapshot)
SELECT $1,'teardown',$2,COALESCE(jsonb_agg(discord_role_id) FILTER (WHERE discord_role_id IS NOT NULL),'[]'::jsonb)
FROM discord_managed_roles WHERE scope='event' AND event_id=$1`,
eventID, time.Now().UTC().UnixNano()); err != nil {
return err
}
tag, err := tx.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
if err != nil {
return err

View File

@@ -54,6 +54,77 @@ func TestMigrationsAndReadiness(t *testing.T) {
if !bracketTable {
t.Fatal("event_bracket_drafts table is missing")
}
for _, table := range []string{"discord_managed_roles", "discord_role_assignments", "discord_role_sync_jobs"} {
var exists bool
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists); err != nil {
t.Fatal(err)
}
if !exists {
t.Fatalf("%s table is missing", table)
}
}
}
func TestDiscordRoleSyncPersistence(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not configured")
}
ctx := context.Background()
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
t.Fatal(err)
}
store, err := Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer store.Close()
eventID := fmt.Sprintf("discord-sync-%d", time.Now().UnixNano())
role := application.DiscordManagedRole{
Scope: application.DiscordRoleScopeEvent, EventID: eventID, TeamID: "team-1",
Kind: application.DiscordRoleKindTeam, DiscordRoleID: eventID + "-role", RoleName: "Alpha",
}
defer func() {
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_managed_roles WHERE event_id=$1`, eventID)
}()
if err = store.UpsertDiscordManagedRole(ctx, role); err != nil {
t.Fatal(err)
}
if err = store.UpsertDiscordRoleAssignment(ctx, role.DiscordRoleID, "discord-user"); err != nil {
t.Fatal(err)
}
assignments, err := store.ListDiscordRoleAssignments(ctx, role.DiscordRoleID)
if err != nil || len(assignments) != 1 || assignments[0] != "discord-user" {
t.Fatalf("unexpected assignments: %v err=%v", assignments, err)
}
if _, err = store.pool.Exec(ctx, `UPDATE discord_role_sync_jobs SET status='completed' WHERE status='pending'`); err != nil {
t.Fatal(err)
}
if err = store.EnqueueDiscordRoleSync(ctx, eventID, application.DiscordRoleActionReconcile, 1); err != nil {
t.Fatal(err)
}
job, err := store.ClaimDiscordRoleSyncJob(ctx)
if err != nil || job.EventID != eventID || len(job.RoleSnapshot) != 1 || job.Attempts != 1 {
t.Fatalf("unexpected claimed job: %+v err=%v", job, err)
}
if err = store.RetryDiscordRoleSyncJob(ctx, job.ID, "retry", time.Now().Add(-time.Second)); err != nil {
t.Fatal(err)
}
job, err = store.ClaimDiscordRoleSyncJob(ctx)
if err != nil || job.Attempts != 2 {
t.Fatalf("unexpected retried job: %+v err=%v", job, err)
}
if err = store.CompleteDiscordRoleSyncJob(ctx, job.ID, "warning"); err != nil {
t.Fatal(err)
}
var status, warning string
if err = store.pool.QueryRow(ctx, `SELECT status,last_error FROM discord_role_sync_jobs WHERE id=$1`, job.ID).Scan(&status, &warning); err != nil {
t.Fatal(err)
}
if status != "completed" || warning != "warning" {
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
}
}
func TestFullScrimPipeline(t *testing.T) {
@@ -82,6 +153,7 @@ func TestFullScrimPipeline(t *testing.T) {
defer func() {
_, _ = store.pool.Exec(ctx, `DELETE FROM audit_log WHERE actor_account_id=$1`, accountID)
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
for _, seriesID := range seriesIDs {
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE id=$1`, seriesID)
}
@@ -97,7 +169,7 @@ func TestFullScrimPipeline(t *testing.T) {
event, err := service.CreateEvent(ctx, admin, domain.Event{
ID: eventID, Name: "Pipeline", StartsAt: now.Add(24 * time.Hour), EndsAt: now.Add(28 * time.Hour),
RegistrationDeadline: now.Add(23 * time.Hour), RulesetID: "standard-control-hybrid-control",
})
}, application.CreateEventOptions{})
if err != nil {
t.Fatal(err)
}
@@ -217,13 +289,14 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
defer func() {
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE tournament_id=$1`, tournamentID)
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
}()
service := application.New(store, discardPublisher{}, nil)
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
})
}, application.CreateEventOptions{})
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,60 @@
package application
import (
"context"
"time"
"mixmaker/backend/internal/domain"
)
const (
DiscordRoleScopeGlobal = "global"
DiscordRoleScopeEvent = "event"
DiscordRoleKindTank = "tank"
DiscordRoleKindDamage = "damage"
DiscordRoleKindSupport = "support"
DiscordRoleKindTeam = "team"
DiscordRoleKindCaptain = "captain"
DiscordRoleActionReconcile = "reconcile"
DiscordRoleActionTeardown = "teardown"
)
type DiscordManagedRole struct {
Scope string
EventID string
TeamID string
Kind string
DiscordRoleID string
RoleName string
}
type DiscordRoleSyncJob struct {
ID int64
EventID string
Action string
Generation int64
RoleSnapshot []string
Attempts int
}
type DiscordRoleRoster struct {
Roster domain.RosterDraft
PlayerDiscordIDs map[string]string
}
type DiscordRoleStore interface {
SeedDiscordRoleSyncJobs(context.Context) error
ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error)
CompleteDiscordRoleSyncJob(context.Context, int64, string) error
RetryDiscordRoleSyncJob(context.Context, int64, string, time.Time) error
ListDiscordManagedRoles(context.Context, string) ([]DiscordManagedRole, error)
UpsertDiscordManagedRole(context.Context, DiscordManagedRole) error
DeleteDiscordManagedRole(context.Context, DiscordManagedRole) error
ListDiscordRoleAssignments(context.Context, string) ([]string, error)
UpsertDiscordRoleAssignment(context.Context, string, string) error
DeleteDiscordRoleAssignment(context.Context, string, string) error
GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error)
ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error)
}

View File

@@ -21,6 +21,7 @@ type Store interface {
DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (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)
CreateEvent(context.Context, domain.Event) (domain.Event, error)
GetEvent(context.Context, string) (domain.Event, error)
@@ -59,7 +60,15 @@ type Store interface {
type Publisher interface{ Publish(topic string, value any) }
type EventAnnouncer interface {
AnnounceEventCreated(context.Context, domain.Event) error
AnnounceEventCreated(context.Context, domain.Event, bool) error
}
type CreateEventOptions struct {
PingEveryone bool
}
func (s *Service) enqueueDiscordRoleSync(ctx context.Context, eventID, action string) {
_ = s.Store.EnqueueDiscordRoleSync(ctx, eventID, action, s.Now().UnixNano())
}
type Service struct {
@@ -159,7 +168,7 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
return out, err
}
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) {
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
}
@@ -177,7 +186,7 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
s.Bus.Publish("events", out)
if s.EventAnnouncer != nil {
announcementCtx, cancel := context.WithTimeout(ctx, s.AnnouncementTimeout)
_ = s.EventAnnouncer.AnnounceEventCreated(announcementCtx, out)
_ = s.EventAnnouncer.AnnounceEventCreated(announcementCtx, out, options.PingEveryone)
cancel()
}
}
@@ -381,6 +390,7 @@ func (s *Service) RenameTeam(ctx context.Context, actor domain.Account, player d
_ = s.Store.AppendAudit(ctx, actor.ID, "team.renamed", teamID, map[string]string{"name": name})
s.Bus.Publish("team:"+teamID, team)
s.Bus.Publish("event:"+team.EventID, team)
s.enqueueDiscordRoleSync(ctx, team.EventID, DiscordRoleActionReconcile)
}
return team, err
}

View File

@@ -33,12 +33,14 @@ func (p *recordingPublisher) Publish(topic string, _ any) {
type recordingAnnouncer struct {
events []domain.Event
pings []bool
err error
hasDeadline bool
}
func (a *recordingAnnouncer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
func (a *recordingAnnouncer) AnnounceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
a.events = append(a.events, event)
a.pings = append(a.pings, pingEveryone)
_, a.hasDeadline = ctx.Deadline()
return a.err
}
@@ -51,7 +53,7 @@ func TestCreateEventAnnouncesAfterPersistence(t *testing.T) {
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if err != nil {
t.Fatal(err)
}
@@ -64,6 +66,9 @@ func TestCreateEventAnnouncesAfterPersistence(t *testing.T) {
if !announcer.hasDeadline {
t.Fatal("announcement context must have a timeout")
}
if len(announcer.pings) != 1 || !announcer.pings[0] {
t.Fatalf("expected @everyone option, got %v", announcer.pings)
}
if len(bus.topics) != 1 || bus.topics[0] != "events" {
t.Fatalf("unexpected published topics: %v", bus.topics)
}
@@ -77,7 +82,7 @@ func TestCreateEventDoesNotAnnouncePersistenceFailure(t *testing.T) {
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
_, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
_, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if !errors.Is(err, persistenceErr) {
t.Fatalf("expected persistence error, got %v", err)
}
@@ -93,7 +98,7 @@ func TestCreateEventIgnoresAnnouncementFailure(t *testing.T) {
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{PingEveryone: true})
if err != nil {
t.Fatalf("announcement failure must not fail event creation: %v", err)
}
@@ -102,6 +107,20 @@ func TestCreateEventIgnoresAnnouncementFailure(t *testing.T) {
}
}
func TestCreateEventCanAnnounceWithoutEveryonePing(t *testing.T) {
announcer := &recordingAnnouncer{}
service := New(&createEventStore{}, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
if _, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now), CreateEventOptions{}); err != nil {
t.Fatal(err)
}
if len(announcer.events) != 1 || len(announcer.pings) != 1 || announcer.pings[0] {
t.Fatalf("expected one announcement without @everyone, events=%d pings=%v", len(announcer.events), announcer.pings)
}
}
func validEvent(now time.Time) domain.Event {
return domain.Event{
Name: "Sunday Mix",

View File

@@ -39,6 +39,7 @@ func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID
_ = s.Store.AppendAudit(ctx, actor.ID, "event.cancelled", eventID, event)
s.Bus.Publish("event:"+eventID, event)
s.Bus.Publish("events", event)
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionTeardown)
}
return event, err
}
@@ -321,6 +322,9 @@ func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, ev
}
_ = s.Store.AppendAudit(ctx, actor.ID, action, eventID, map[string]string{"outgoingPlayerId": outgoingID, "incomingPlayerId": reserveID})
s.Bus.Publish("event:"+eventID, roster)
if emergency {
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionReconcile)
}
}
return roster, err
}
@@ -389,6 +393,7 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "roster.confirmed", eventID, roster)
s.Bus.Publish("event:"+eventID, event)
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionReconcile)
}
return event, err
}
@@ -571,6 +576,9 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "workflow.reverted", eventID, map[string]domain.EventState{"state": previous})
s.Bus.Publish("event:"+eventID, event)
if previous == domain.RostersDraft {
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionTeardown)
}
}
return event, err
}
@@ -684,6 +692,9 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, getErr = s.Store.SaveEventWorkflow(ctx, event, old)
if getErr == nil {
s.enqueueDiscordRoleSync(ctx, out.EventID, DiscordRoleActionTeardown)
}
}
return out, getErr
}
@@ -736,6 +747,9 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, getErr = s.Store.SaveEventWorkflow(ctx, event, old)
if getErr == nil {
s.enqueueDiscordRoleSync(ctx, out.EventID, DiscordRoleActionTeardown)
}
}
err = getErr
}
@@ -799,6 +813,9 @@ func (s *Service) advanceGraphTournament(ctx context.Context, completed domain.S
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, err = s.Store.SaveEventWorkflow(ctx, event, old)
if err == nil {
s.enqueueDiscordRoleSync(ctx, completed.EventID, DiscordRoleActionTeardown)
}
}
return err
}

View File

@@ -259,9 +259,24 @@ func (r *RosterDraft) MoveToReserve(teamID, playerID string) error {
}
func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
replaceCaptain := false
for _, team := range r.Teams {
if team.ID == teamID && team.CaptainPlayerID == outgoingID {
replaceCaptain = true
break
}
}
confirmed := r.Confirmed
r.Confirmed = false
err := r.Substitute(teamID, outgoingID, reserveID, rating)
r.Confirmed = confirmed
if err == nil && replaceCaptain {
for index := range r.Teams {
if r.Teams[index].ID == teamID {
r.Teams[index].CaptainPlayerID = reserveID
break
}
}
}
return err
}

View File

@@ -83,6 +83,22 @@ func TestRosterDragOperationsPreserveEmptyRoleSlots(t *testing.T) {
}
}
func TestEmergencySubstitutionTransfersCaptain(t *testing.T) {
roster := testRoster()
roster.Confirmed = true
roster.Teams[0].CaptainPlayerID = "a-t"
roster.Teams[1].CaptainPlayerID = "b-t"
if err := roster.EmergencySubstitute("a", "a-t", "reserve", 31); err != nil {
t.Fatal(err)
}
if roster.Teams[0].CaptainPlayerID != "reserve" {
t.Fatalf("captain role was not transferred to emergency substitute: %q", roster.Teams[0].CaptainPlayerID)
}
if err := roster.Validate(true); err != nil {
t.Fatalf("emergency substitution left an invalid confirmed roster: %v", err)
}
}
func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
rules := testRules()
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)

View File

@@ -0,0 +1,42 @@
-- +mixmaker Up
CREATE TABLE discord_managed_roles (
scope text NOT NULL CHECK (scope IN ('global', 'event')),
event_id text NOT NULL DEFAULT '',
team_id text NOT NULL DEFAULT '',
kind text NOT NULL CHECK (kind IN ('tank', 'damage', 'support', 'team', 'captain')),
discord_role_id text NOT NULL UNIQUE,
role_name text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (scope, event_id, team_id, kind)
);
CREATE TABLE discord_role_assignments (
discord_role_id text NOT NULL REFERENCES discord_managed_roles(discord_role_id) ON DELETE CASCADE,
discord_user_id text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (discord_role_id, discord_user_id)
);
CREATE TABLE discord_role_sync_jobs (
id bigserial PRIMARY KEY,
event_id text NOT NULL,
action text NOT NULL CHECK (action IN ('reconcile', 'teardown')),
generation bigint NOT NULL,
role_snapshot jsonb NOT NULL DEFAULT '[]'::jsonb,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed')),
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
UNIQUE (event_id, action, generation)
);
CREATE INDEX discord_role_sync_jobs_claim_idx
ON discord_role_sync_jobs(status, available_at, id);
-- +mixmaker Down
DROP TABLE IF EXISTS discord_role_sync_jobs;
DROP TABLE IF EXISTS discord_role_assignments;
DROP TABLE IF EXISTS discord_managed_roles;

View File

@@ -44,6 +44,7 @@ services:
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-}
DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-}
DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru}
DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-}
LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on:
migrations:

View File

@@ -22,6 +22,7 @@ Set these as production variables in Coolify:
- `DISCORD_BOT_TOKEN` — bot token from the Developer Portal; never expose it to the frontend
- `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements
- `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en`
- `DISCORD_GUILD_ID` — numeric ID of the server where roster roles are synchronized
Do not copy production values into `.env` in the repository.
@@ -36,6 +37,8 @@ In the Discord Developer Portal:
For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled.
For roster synchronization, grant the bot `Manage Roles`, copy the server ID to `DISCORD_GUILD_ID`, and place the bot's own role above all roles it creates. The bot creates global `Tank`, `Damage`, and `Support` roles plus temporary team and team-captain roles after roster confirmation. It does not need `Administrator` or privileged Gateway intents. Role synchronization stays disabled when `DISCORD_GUILD_ID` is empty.
## Storage and backups
PostgreSQL uses the `postgres_data` named volume. Confirm that Coolify recognizes it as persistent storage before accepting users.
@@ -50,7 +53,10 @@ Persistent storage is not a backup. Schedule an encrypted `pg_dump` to storage o
4. Complete Discord login and verify the admin role.
5. Create a test event and RSVP.
6. Confirm the bot posts one announcement with an `@everyone` mention and a working registration button.
7. Restart/redeploy and confirm the event remains.
8. Verify SSE remains connected through the proxy.
7. Confirm test rosters and verify dynamic team, captain, and slot roles in Discord.
8. Rename a live team and verify both managed role names change.
9. Complete or cancel the test event and verify temporary roles are removed.
10. Restart/redeploy and confirm the event remains.
11. Verify SSE remains connected through the proxy.
Rollback application images only when the database migration is backward-compatible. Otherwise restore the matching database backup.

View File

@@ -353,11 +353,15 @@ function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreat
const language = useLanguage()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState<EventInput>(initialForm)
useEffect(() => setForm(initialForm), [initialForm])
const [pingEveryone, setPingEveryone] = useState(true)
useEffect(() => {
setForm(initialForm)
setPingEveryone(true)
}, [initialForm])
const save = useMutation({
mutationFn: () => {
const payload = { ...form, startsAt: new Date(form.startsAt).toISOString(), endsAt: new Date(form.endsAt).toISOString(), registrationDeadline: new Date(form.registrationDeadline).toISOString() }
return event ? api.updateEvent(event.id, payload) : api.createEvent(payload)
return event ? api.updateEvent(event.id, payload) : api.createEvent({ ...payload, pingEveryone })
},
onSuccess: (saved) => {
void client.invalidateQueries({ queryKey: ['events'] })
@@ -404,6 +408,7 @@ function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreat
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => setStart(e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required readOnly type="datetime-local" value={form.registrationDeadline} /></label></div>
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
{!event && <label className="announcement-toggle"><input type="checkbox" checked={pingEveryone} onChange={(e) => setPingEveryone(e.target.checked)} /><span><strong>Ping everyone in Discord</strong><small>The announcement is always posted; this controls the @everyone notification.</small></span></label>}
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
</form>
<aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside>

View File

@@ -122,6 +122,9 @@ export interface EventInput {
endsAt: string
registrationDeadline: string
}
export interface CreateEventInput extends EventInput {
pingEveryone: boolean
}
export type BracketSlotSource =
| { kind: 'Team'; teamId: string; matchId?: never }
| { kind: 'Winner' | 'Loser'; matchId: string; teamId?: never }
@@ -588,7 +591,7 @@ export const api = {
},
removeParticipant: (eventId: string, playerId: string) =>
request<void>(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
createEvent: async (event: CreateEventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
cancelEvent: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),

View File

@@ -131,6 +131,8 @@ const translations: Record<string, string> = {
'End time': 'Время окончания',
'Registration deadline': 'Крайний срок регистрации',
'Description': 'Описание',
'Ping everyone in Discord': 'Пинговать всех в Discord',
'The announcement is always posted; this controls the @everyone notification.': 'Анонс публикуется всегда; эта настройка управляет уведомлением @everyone.',
'Times are sent to the server in UTC.': 'Время отправляется на сервер в UTC.',
'Save changes': 'Сохранить изменения',
'Server authority': 'Сервер — источник истины',

View File

@@ -251,6 +251,7 @@ textarea { min-height: 85px; resize: vertical; }
.roster-reserve > div:first-child { display: flex; gap: 9px; align-items: center; margin-bottom: 13px; }.roster-reserve > div:first-child svg { width: 17px; color: var(--orange); }.roster-reserve span { display: grid; }.roster-reserve small,.reserve-list p { color: var(--muted); font-size: 9px; }
.reserve-list { display: grid; gap: 7px; }.reserve-list button { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); cursor: grab; text-align: left; }.reserve-list button:active { cursor: grabbing; }.mini-avatar { width: 25px; height: 25px; display: grid !important; place-items: center; border-radius: 5px; background: #253728; color: var(--green); font-size: 8px; }
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
.announcement-toggle { display: flex; align-items: center; gap: 10px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; cursor: pointer; }.announcement-toggle input { accent-color: var(--orange); }.announcement-toggle span { display: grid; gap: 3px; }.announcement-toggle strong { font-size: 11px; }.announcement-toggle small { color: var(--muted); font-size: 9px; }
.bracket-editor { display: grid; gap: 12px; padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }.bracket-editor-toolbar,.bracket-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.bracket-editor-toolbar h2 { margin: 5px 0; }.bracket-editor-toolbar p,.bracket-editor-footer p { color: var(--muted); font-size: 10px; }.bracket-editor-toolbar > div:last-child,.bracket-editor-footer > div:last-child { display: flex; align-items: center; gap: 8px; }.bracket-source-palette { display: grid; grid-template-columns: minmax(220px,1fr) 2fr; gap: 10px; padding: 12px; border: 1px dashed #3d454f; border-radius: 9px; background: #0d1014; }.bracket-source-palette > div { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }.bracket-source-palette > div > strong { width: 100%; color: var(--muted); font-size: 9px; text-transform: uppercase; }.bracket-source-palette button { padding: 7px 9px; display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; background: #151920; color: var(--text); cursor: grab; font-size: 9px; }.bracket-source-palette button.selected { border-color: var(--orange); background: var(--orange-soft); }.bracket-source-palette button svg { width: 12px; }.bracket-source-palette span { display: contents; }.bracket-editor-scroll { min-height: 390px; padding: 20px; display: flex; align-items: stretch; gap: 70px; overflow-x: auto; border: 1px solid var(--border); border-radius: 9px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }.bracket-editor-round { min-width: 280px; display: flex; flex-direction: column; gap: 18px; }.bracket-editor-round > header { display: flex; align-items: center; justify-content: space-between; }.bracket-editor-round > header span { color: var(--orange); font-size: 8px; text-transform: uppercase; }.bracket-editor-round h3 { margin-top: 4px; font-size: 16px; }.bracket-editor-match { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); position: relative; }.bracket-editor-match::after { content: ""; width: 70px; height: 1px; position: absolute; right: -71px; top: 50%; background: #303640; }.bracket-editor-round:last-child .bracket-editor-match::after { display: none; }.bracket-editor-match .match-label button { width: 24px; height: 24px; }.bracket-slot-drop { width: 100%; min-height: 54px; padding: 9px 12px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 4px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--text); text-align: left; }.bracket-slot-drop:last-child { border-bottom: 0; }.bracket-slot-drop small { color: var(--muted); font-size: 8px; }.bracket-slot-drop.empty { border: 1px dashed #65452e; background: rgba(255,122,26,.04); }.bracket-confirmed { display: inline-flex; align-items: center; gap: 5px; color: var(--green); font-size: 10px; }.bracket-confirmed svg { width: 15px; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }

View File

@@ -6,7 +6,8 @@
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен опциональный Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed с `@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 игроки сохраняются как предупреждения и не блокируют микс.
## Подтверждённые требования
@@ -32,7 +33,8 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
- PostgreSQL в том же Compose-стеке с persistent volume;
- мобильный и десктопный интерфейс.
- карточки отменённых событий отображаются красными, завершённых — зелёными, активных — розовыми с меткой LIVE.
- новый микс может автоматически анонсироваться в настроенном Discord-канале со ссылкой на регистрацию.
- новый микс автоматически анонсируется в настроенном Discord-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании.
- подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла.
## Исходный регламент
@@ -79,6 +81,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
## Ближайший следующий шаг
Проверить миграцию `006_event_workflow.sql` и полный сценарий на staging с реальной PostgreSQL (`TEST_DATABASE_URL`), проверить тестовый Discord-анонс с production bot token/channel ID, затем провести первый пробный микс через production UI.
Проверить миграции `006_event_workflow.sql` и `011_discord_role_sync.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить анонс, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом.
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.

View File

@@ -114,10 +114,14 @@ Application-слой управляет единым versioned workflow собы
### Discord-уведомления
После успешного создания события application-слой вызывает consumer-owned `EventAnnouncer` port. Discord REST adapter публикует в настроенный текстовый канал локализованный RU/EN embed с `@everyone`, временем начала и ссылкой на `/events/{eventId}`. Внешний вызов имеет короткий timeout и выполняется только после сохранения события: ошибка Discord логируется, но не откатывает созданный микс и не превращает успешную команду в HTTP 500.
После успешного создания события application-слой вызывает consumer-owned `EventAnnouncer` port. Discord REST adapter публикует в настроенный текстовый канал локализованный RU/EN embed, время начала и ссылку на `/events/{eventId}`. Create-команда передаёт отдельный флаг: по умолчанию сообщение содержит разрешённый `@everyone`, а при отключённой галочке тот же анонс отправляется без mention. Флаг не сохраняется в Event, потому что обновления не создают повторный анонс. Внешний вызов имеет короткий timeout и выполняется только после сохранения события: ошибка Discord логируется, но не откатывает созданный микс и не превращает успешную команду в HTTP 500.
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
После подтверждения 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. При отмене, завершении, удалении или откате подтверждения event-specific team/captain roles удаляются. Общие ролевые назначения пересчитываются по всем другим активным миксам, поэтому параллельные события не снимают нужную роль. Role worker включается только при наличии `DISCORD_GUILD_ID` и не требует Gateway intents.
### Капитаны
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.

View File

@@ -22,7 +22,13 @@
### Discord-анонс события
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Параметр команды создания определяет, сопровождается ли анонс уведомлением `@everyone`; он не является сохраняемым состоянием Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
### Discord-роли состава
Подтверждённый Roster является источником desired state для внешних Discord-ролей. Каждый участник получает временную роль с `Team.Name`, роль своего slot-а Tank/Damage/Support, а капитан — отдельную `${Team.Name} Captain`. Discord role ID и выданные ботом назначения сохраняются отдельно от доменных Team и Account, чтобы повторная синхронизация была идемпотентной.
Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции.
### Жизненный цикл события
@@ -38,7 +44,7 @@
8. `Completed` — выполнены все запланированные матчи;
9. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит; если заменён капитан, назначение переходит вошедшему вместо него игроку. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
### Team

View File

@@ -81,7 +81,8 @@
- повторная жеребьёвка с защитой от одинаковых составов;
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
- обновление анонса, напоминания, публикация составов и результатов;
- синхронизация временных Discord-ролей и командных каналов;
- [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей;
- создание и очистка временных командных голосовых каналов;
- импорт данных и резервное копирование.
## Идеи после MVP

View File

@@ -171,7 +171,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/EventInput"
$ref: "#/components/schemas/EventCreateInput"
responses:
"201":
description: Event created
@@ -1034,6 +1034,15 @@ components:
type: string
format: date-time
description: Synchronized by the server to startsAt.
EventCreateInput:
allOf:
- $ref: "#/components/schemas/EventInput"
- type: object
properties:
pingEveryone:
type: boolean
default: true
description: Whether the Discord announcement should notify @everyone. The announcement is still posted when false.
Event:
allOf:
- $ref: "#/components/schemas/EventInput"