From c3624376b0a8eb60e8477db89b6dfce76948aa68 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 11:34:11 +0300 Subject: [PATCH] Add Discord guild ID configuration and enhance event announcement options 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. --- .env.example | 1 + backend/cmd/api/main.go | 18 +- backend/internal/adapter/discord/announcer.go | 20 +- .../adapter/discord/announcer_test.go | 24 +- backend/internal/adapter/discord/rolesync.go | 518 ++++++++++++++++++ .../internal/adapter/discord/rolesync_test.go | 384 +++++++++++++ backend/internal/adapter/httpapi/server.go | 23 +- .../internal/adapter/httpapi/server_test.go | 16 + .../adapter/postgres/discord_roles.go | 215 ++++++++ backend/internal/adapter/postgres/store.go | 6 + .../postgres/store_integration_test.go | 77 ++- backend/internal/application/discord_roles.go | 60 ++ backend/internal/application/service.go | 16 +- backend/internal/application/service_test.go | 27 +- backend/internal/application/workflow.go | 17 + backend/internal/domain/event_workflow.go | 15 + backend/internal/domain/workflow_test.go | 16 + backend/migrations/011_discord_role_sync.sql | 42 ++ compose.yaml | 1 + deployments/coolify.md | 10 +- frontend/src/App.tsx | 9 +- frontend/src/api/client.ts | 5 +- frontend/src/i18n.tsx | 2 + frontend/src/index.css | 1 + memory_bank/active_context.md | 8 +- memory_bank/architecture.md | 6 +- memory_bank/domain_model.md | 10 +- memory_bank/roadmap.md | 3 +- openapi/openapi.yaml | 11 +- 29 files changed, 1524 insertions(+), 37 deletions(-) create mode 100644 backend/internal/adapter/discord/rolesync.go create mode 100644 backend/internal/adapter/discord/rolesync_test.go create mode 100644 backend/internal/adapter/httpapi/server_test.go create mode 100644 backend/internal/adapter/postgres/discord_roles.go create mode 100644 backend/internal/application/discord_roles.go create mode 100644 backend/migrations/011_discord_role_sync.sql diff --git a/.env.example b/.env.example index ec0750e..9a0c33d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c9e3301..18fca15 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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 { diff --git a/backend/internal/adapter/discord/announcer.go b/backend/internal/adapter/discord/announcer.go index 1b47142..34596e1 100644 --- a/backend/internal/adapter/discord/announcer.go +++ b/backend/internal/adapter/discord/announcer.go @@ -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 { diff --git a/backend/internal/adapter/discord/announcer_test.go b/backend/internal/adapter/discord/announcer_test.go index ec0b3d8..cefbc60 100644 --- a/backend/internal/adapter/discord/announcer_test.go +++ b/backend/internal/adapter/discord/announcer_test.go @@ -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") } } diff --git a/backend/internal/adapter/discord/rolesync.go b/backend/internal/adapter/discord/rolesync.go new file mode 100644 index 0000000..1f1dbfa --- /dev/null +++ b/backend/internal/adapter/discord/rolesync.go @@ -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<>'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>'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 +} diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go index 5ca4927..20e0220 100644 --- a/backend/internal/adapter/postgres/store.go +++ b/backend/internal/adapter/postgres/store.go @@ -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 diff --git a/backend/internal/adapter/postgres/store_integration_test.go b/backend/internal/adapter/postgres/store_integration_test.go index 1b78157..4a70751 100644 --- a/backend/internal/adapter/postgres/store_integration_test.go +++ b/backend/internal/adapter/postgres/store_integration_test.go @@ -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) } diff --git a/backend/internal/application/discord_roles.go b/backend/internal/application/discord_roles.go new file mode 100644 index 0000000..26148f2 --- /dev/null +++ b/backend/internal/application/discord_roles.go @@ -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) +} diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index 4a59c4a..c2eb73a 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -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 } diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index e8c58cd..735b066 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -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", diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index 87ba598..522a943 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -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 } diff --git a/backend/internal/domain/event_workflow.go b/backend/internal/domain/event_workflow.go index 5bf0a41..2842c15 100644 --- a/backend/internal/domain/event_workflow.go +++ b/backend/internal/domain/event_workflow.go @@ -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 } diff --git a/backend/internal/domain/workflow_test.go b/backend/internal/domain/workflow_test.go index 6e80b01..1d3f448 100644 --- a/backend/internal/domain/workflow_test.go +++ b/backend/internal/domain/workflow_test.go @@ -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) diff --git a/backend/migrations/011_discord_role_sync.sql b/backend/migrations/011_discord_role_sync.sql new file mode 100644 index 0000000..e56a0ca --- /dev/null +++ b/backend/migrations/011_discord_role_sync.sql @@ -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; diff --git a/compose.yaml b/compose.yaml index 494066e..109c719 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/deployments/coolify.md b/deployments/coolify.md index 0a3b6bd..8663d2c 100644 --- a/deployments/coolify.md +++ b/deployments/coolify.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f311c28..22d7026 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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

{event ? 'Event settings' : 'New event'}

Scheduling and registration

{event &&
{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}
}