From 966c81ba1258d993ec510cdd8aa6664817a5fef1 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 11:42:40 +0300 Subject: [PATCH] Add RSVP role synchronization and localization support for Discord This commit introduces functionality for managing event-specific RSVP roles in Discord, including roles for "Registered", "Going", "Maybe", and "Not Going". The `RoleWorker` has been enhanced to handle these roles based on user registrations, with localization support for role names in Russian and English. Additionally, the database schema has been updated to accommodate new role types and actions, and the service layer has been modified to trigger role synchronization upon relevant event updates. Tests have been added to ensure the correct behavior of the new RSVP handling logic. --- backend/cmd/api/main.go | 1 + backend/internal/adapter/discord/rolesync.go | 129 +++++++++++++++- .../internal/adapter/discord/rolesync_test.go | 144 ++++++++++++++++-- .../adapter/postgres/discord_roles.go | 73 ++++++--- backend/internal/application/discord_roles.go | 22 ++- backend/internal/application/service.go | 4 + backend/migrations/012_discord_rsvp_roles.sql | 29 ++++ deployments/coolify.md | 2 +- memory_bank/active_context.md | 4 +- memory_bank/architecture.md | 2 + memory_bank/domain_model.md | 2 + memory_bank/roadmap.md | 1 + 12 files changed, 373 insertions(+), 40 deletions(-) create mode 100644 backend/migrations/012_discord_rsvp_roles.sql diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 18fca15..f83fb7d 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -61,6 +61,7 @@ func main() { roleWorker, roleErr := discordadapter.NewRoleWorker(store, discordadapter.RoleSyncConfig{ BotToken: discordBotToken, GuildID: discordGuildID, + Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"), }) if roleErr != nil { slog.Error("Discord role sync configuration failed", "error", roleErr) diff --git a/backend/internal/adapter/discord/rolesync.go b/backend/internal/adapter/discord/rolesync.go index 1f1dbfa..baa1ce7 100644 --- a/backend/internal/adapter/discord/rolesync.go +++ b/backend/internal/adapter/discord/rolesync.go @@ -22,6 +22,7 @@ import ( type RoleSyncConfig struct { BotToken string GuildID string + Locale string APIBaseURL string HTTPClient *http.Client PollInterval time.Duration @@ -33,6 +34,7 @@ type RoleWorker struct { manager *RoleManager pollInterval time.Duration jobTimeout time.Duration + locale string now func() time.Time } @@ -74,11 +76,19 @@ func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (* if jobTimeout <= 0 { jobTimeout = 30 * time.Second } + locale := strings.ToLower(strings.TrimSpace(config.Locale)) + if locale == "" { + locale = "ru" + } + if locale != "ru" && locale != "en" { + return nil, fmt.Errorf("unsupported Discord role locale %q", locale) + } return &RoleWorker{ store: store, manager: manager, pollInterval: pollInterval, jobTimeout: jobTimeout, + locale: locale, now: func() time.Time { return time.Now().UTC() }, }, nil } @@ -141,9 +151,12 @@ func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) { jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout) defer cancel() var warnings []string - if job.Action == application.DiscordRoleActionTeardown { + switch job.Action { + case application.DiscordRoleActionTeardown: warnings, err = w.teardown(jobCtx, job) - } else { + case application.DiscordRoleActionRSVP: + warnings, err = w.reconcileRSVP(jobCtx, job.EventID) + default: warnings, err = w.reconcile(jobCtx, job.EventID) } if err == nil { @@ -189,6 +202,76 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e return warnings, nil } +func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]string, error) { + state, err := w.store.GetDiscordRoleRegistration(ctx, eventID) + if errors.Is(err, domain.ErrNotFound) { + return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID}) + } + if err != nil { + return nil, err + } + if state.Event.State == domain.Completed || state.Event.State == domain.Cancelled { + return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID}) + } + existing, err := w.store.ListDiscordManagedRoles(ctx, eventID) + if err != nil { + return nil, err + } + registrationKinds := map[string]bool{ + application.DiscordRoleKindRegistered: true, + application.DiscordRoleKindGoing: true, + application.DiscordRoleKindMaybe: true, + application.DiscordRoleKindNotGoing: true, + } + if len(state.Registrations) == 0 { + hasRegistrationRoles := false + for _, role := range existing { + hasRegistrationRoles = hasRegistrationRoles || registrationKinds[role.Kind] + } + if !hasRegistrationRoles { + return nil, nil + } + } + names := rsvpRoleNames(w.locale, state.Event.Name) + desiredRoles := []application.DiscordManagedRole{ + {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered]}, + {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindGoing, RoleName: names[application.DiscordRoleKindGoing]}, + {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindMaybe, RoleName: names[application.DiscordRoleKindMaybe]}, + {Scope: application.DiscordRoleScopeEvent, EventID: eventID, Kind: application.DiscordRoleKindNotGoing, RoleName: names[application.DiscordRoleKindNotGoing]}, + } + roles, err := w.ensureRoles(ctx, desiredRoles, existing, registrationKinds) + if err != nil { + return nil, err + } + byKind := make(map[string]application.DiscordManagedRole, len(roles)) + desiredAssignments := make(map[string]map[string]bool, len(roles)) + for _, role := range roles { + byKind[role.Kind] = role + desiredAssignments[role.DiscordRoleID] = make(map[string]bool) + } + warnings := make([]string, 0) + for _, registration := range state.Registrations { + discordID := state.PlayerDiscordIDs[registration.PlayerID] + if discordID == "" { + warnings = append(warnings, "player "+registration.PlayerID+" has no linked Discord account") + continue + } + desiredAssignments[byKind[application.DiscordRoleKindRegistered].DiscordRoleID][discordID] = true + kind := rsvpStatusKind(registration.Status) + if role := byKind[kind]; role.DiscordRoleID != "" { + desiredAssignments[role.DiscordRoleID][discordID] = true + } + } + for _, role := range roles { + roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desiredAssignments[role.DiscordRoleID]) + warnings = append(warnings, roleWarnings...) + if syncErr != nil { + return warnings, syncErr + } + } + return uniqueStrings(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"}, @@ -202,6 +285,12 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)}, ) } + return w.ensureRoles(ctx, desired, existing, map[string]bool{ + application.DiscordRoleKindTeam: true, application.DiscordRoleKindCaptain: true, + }) +} + +func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []application.DiscordManagedRole, removableKinds map[string]bool) ([]application.DiscordManagedRole, error) { existingByKey := make(map[string]application.DiscordManagedRole, len(existing)) for _, role := range existing { existingByKey[managedRoleKey(role)] = role @@ -235,7 +324,7 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste result = append(result, role) } for _, role := range existing { - if role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || desiredKeys[managedRoleKey(role)] { + if !removableKinds[role.Kind] || desiredKeys[managedRoleKey(role)] { continue } if err := w.deleteManagedRole(ctx, role); err != nil { @@ -499,6 +588,40 @@ func roleKind(role domain.Role) string { } } +func rsvpStatusKind(status domain.RSVPStatus) string { + switch status { + case domain.Going: + return application.DiscordRoleKindGoing + case domain.Maybe: + return application.DiscordRoleKindMaybe + case domain.NotGoing: + return application.DiscordRoleKindNotGoing + default: + return "" + } +} + +func rsvpRoleNames(locale, eventName string) map[string]string { + prefixes := map[string]string{ + application.DiscordRoleKindRegistered: "Зарегистрирован: ", + application.DiscordRoleKindGoing: "Идёт: ", + application.DiscordRoleKindMaybe: "Возможно: ", + application.DiscordRoleKindNotGoing: "Не идёт: ", + } + if locale == "en" { + prefixes = map[string]string{ + application.DiscordRoleKindRegistered: "Registered: ", + application.DiscordRoleKindGoing: "Going: ", + application.DiscordRoleKindMaybe: "Maybe: ", + application.DiscordRoleKindNotGoing: "Not going: ", + } + } + for kind, prefix := range prefixes { + prefixes[kind] = truncate(prefix+strings.TrimSpace(eventName), 100) + } + return prefixes +} + func isDiscordNotFound(err error) bool { var httpErr *discordHTTPError return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound diff --git a/backend/internal/adapter/discord/rolesync_test.go b/backend/internal/adapter/discord/rolesync_test.go index 7a33045..525580a 100644 --- a/backend/internal/adapter/discord/rolesync_test.go +++ b/backend/internal/adapter/discord/rolesync_test.go @@ -17,20 +17,22 @@ import ( ) 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 + mu sync.Mutex + jobs []application.DiscordRoleSyncJob + roles map[string]application.DiscordManagedRole + assignments map[string]map[string]bool + rosters map[string]application.DiscordRoleRoster + registrations map[string]application.DiscordRoleRegistration + 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), + roles: make(map[string]application.DiscordManagedRole), + assignments: make(map[string]map[string]bool), + rosters: make(map[string]application.DiscordRoleRoster), + registrations: make(map[string]application.DiscordRoleRegistration), } } @@ -112,6 +114,14 @@ func (s *roleStoreFake) ListActiveDiscordRoleRosters(context.Context) ([]applica return s.active, nil } +func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID string) (application.DiscordRoleRegistration, error) { + registration, ok := s.registrations[eventID] + if !ok { + return registration, domain.ErrNotFound + } + return registration, nil +} + func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { var mu sync.Mutex roleNames := make(map[string]string) @@ -145,6 +155,11 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { item := testDiscordRoster("Alpha") store.rosters["event-1"] = item store.active = []application.DiscordRoleRoster{item} + rsvpRole := application.DiscordManagedRole{ + Scope: application.DiscordRoleScopeEvent, EventID: "event-1", + Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", + } + store.roles[managedRoleKey(rsvpRole)] = rsvpRole 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(), @@ -167,6 +182,9 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { if got := assignmentCount(store.assignments); got != 11 { t.Fatalf("expected 11 managed assignments, got %d", got) } + if _, ok := store.roles[managedRoleKey(rsvpRole)]; !ok { + t.Fatal("roster reconcile deleted an RSVP role") + } firstRequestCount := len(methods) store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionReconcile}} @@ -213,6 +231,93 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { } } +func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) { + nextID := 0 + patches := 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": "rsvp-role-" + string(rune('0'+nextID))}) + return + } + if request.Method == http.MethodPatch { + patches++ + } + response.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + store := newRoleStoreFake() + store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Going) + store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + worker, err := NewRoleWorker(store, RoleSyncConfig{ + BotToken: "token", GuildID: "guild", Locale: "ru", APIBaseURL: server.URL, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + registeredID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindRegistered) + goingID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindGoing) + maybeID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindMaybe) + notGoingID := eventRoleID(store.roles, "event-1", application.DiscordRoleKindNotGoing) + if !store.assignments[registeredID]["user-1"] || !store.assignments[goingID]["user-1"] { + t.Fatalf("initial Going roles were not assigned: %v", store.assignments) + } + + store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.Maybe) + store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + if store.assignments[goingID]["user-1"] || !store.assignments[maybeID]["user-1"] || !store.assignments[registeredID]["user-1"] { + t.Fatalf("Going to Maybe transition was not reconciled: %v", store.assignments) + } + + store.registrations["event-1"] = testDiscordRegistration("Sunday Mix", domain.NotGoing) + store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + if store.assignments[maybeID]["user-1"] || !store.assignments[notGoingID]["user-1"] || !store.assignments[registeredID]["user-1"] { + t.Fatalf("Maybe to NotGoing transition was not reconciled: %v", store.assignments) + } + + renamed := testDiscordRegistration("Night Mix", domain.NotGoing) + store.registrations["event-1"] = renamed + store.jobs = []application.DiscordRoleSyncJob{{ID: 4, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + if patches != 4 { + t.Fatalf("expected four RSVP role renames, got %d", patches) + } + + renamed.Registrations = nil + renamed.PlayerDiscordIDs = map[string]string{} + store.registrations["event-1"] = renamed + store.jobs = []application.DiscordRoleSyncJob{{ID: 5, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + for _, roleID := range []string{registeredID, goingID, maybeID, notGoingID} { + if len(store.assignments[roleID]) != 0 { + t.Fatalf("RSVP deletion left assignments on role %s: %v", roleID, store.assignments[roleID]) + } + } + + renamed.Registrations = []domain.RSVP{{EventID: "event-1", PlayerID: "guest", Status: domain.Going}} + store.registrations["event-1"] = renamed + store.jobs = []application.DiscordRoleSyncJob{{ID: 6, EventID: "event-1", Action: application.DiscordRoleActionRSVP}} + if _, err = worker.ProcessNext(context.Background()); err != nil { + t.Fatal(err) + } + if warning := store.warnings[len(store.warnings)-1]; !strings.Contains(warning, "has no linked Discord account") { + t.Fatalf("guest warning was not stored: %q", warning) + } +} + func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodPost { @@ -366,6 +471,16 @@ func testDiscordRoster(name string) application.DiscordRoleRoster { } } +func testDiscordRegistration(name string, status domain.RSVPStatus) application.DiscordRoleRegistration { + return application.DiscordRoleRegistration{ + Event: domain.Event{ID: "event-1", Name: name, State: domain.RegistrationOpen}, + Registrations: []domain.RSVP{{ + EventID: "event-1", PlayerID: "player-1", Status: status, + }}, + PlayerDiscordIDs: map[string]string{"player-1": "user-1"}, + } +} + func assignmentCount(assignments map[string]map[string]bool) int { total := 0 for _, users := range assignments { @@ -382,3 +497,12 @@ func managedRoleID(roles map[string]application.DiscordManagedRole, kind string) } return "" } + +func eventRoleID(roles map[string]application.DiscordManagedRole, eventID, kind string) string { + for _, role := range roles { + if role.Scope == application.DiscordRoleScopeEvent && role.EventID == eventID && role.Kind == kind { + return role.DiscordRoleID + } + } + return "" +} diff --git a/backend/internal/adapter/postgres/discord_roles.go b/backend/internal/adapter/postgres/discord_roles.go index 90e778a..f64843c 100644 --- a/backend/internal/adapter/postgres/discord_roles.go +++ b/backend/internal/adapter/postgres/discord_roles.go @@ -20,12 +20,19 @@ func (s *Store) EnqueueDiscordRoleSync(ctx context.Context, eventID, action stri } func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error { - _, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation) + if _, 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`); err != nil { + return err + } + _, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation) + SELECT DISTINCT e.id,'rsvp',e.version + FROM events e JOIN rsvps r ON r.event_id=e.id + WHERE e.state NOT IN ('Completed','Cancelled') ON CONFLICT(event_id,action,generation) DO NOTHING`) return err } @@ -182,6 +189,26 @@ func (s *Store) ListActiveDiscordRoleRosters(ctx context.Context) ([]application return out, nil } +func (s *Store) GetDiscordRoleRegistration(ctx context.Context, eventID string) (application.DiscordRoleRegistration, error) { + event, err := s.GetEvent(ctx, eventID) + if err != nil { + return application.DiscordRoleRegistration{}, err + } + registrations, err := s.ListRSVPs(ctx, eventID) + if err != nil { + return application.DiscordRoleRegistration{}, err + } + playerIDs := make([]string, 0, len(registrations)) + for _, registration := range registrations { + playerIDs = append(playerIDs, registration.PlayerID) + } + discordIDs, err := s.playerDiscordIDs(ctx, playerIDs) + if err != nil { + return application.DiscordRoleRegistration{}, err + } + return application.DiscordRoleRegistration{Event: event, Registrations: registrations, PlayerDiscordIDs: discordIDs}, nil +} + func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) { playerIDs := make([]string, 0) for _, team := range roster.Teams { @@ -191,25 +218,31 @@ func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft } } } - 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 - } + discordIDs, err := s.playerDiscordIDs(ctx, playerIDs) + if err != nil { + return application.DiscordRoleRoster{}, err } return application.DiscordRoleRoster{Roster: roster, PlayerDiscordIDs: discordIDs}, nil } + +func (s *Store) playerDiscordIDs(ctx context.Context, playerIDs []string) (map[string]string, error) { + discordIDs := make(map[string]string) + if len(playerIDs) == 0 { + return discordIDs, nil + } + 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 nil, err + } + defer rows.Close() + for rows.Next() { + var playerID, discordID string + if err = rows.Scan(&playerID, &discordID); err != nil { + return nil, err + } + discordIDs[playerID] = discordID + } + return discordIDs, rows.Err() +} diff --git a/backend/internal/application/discord_roles.go b/backend/internal/application/discord_roles.go index 26148f2..8e8b95a 100644 --- a/backend/internal/application/discord_roles.go +++ b/backend/internal/application/discord_roles.go @@ -11,13 +11,18 @@ const ( DiscordRoleScopeGlobal = "global" DiscordRoleScopeEvent = "event" - DiscordRoleKindTank = "tank" - DiscordRoleKindDamage = "damage" - DiscordRoleKindSupport = "support" - DiscordRoleKindTeam = "team" - DiscordRoleKindCaptain = "captain" + DiscordRoleKindTank = "tank" + DiscordRoleKindDamage = "damage" + DiscordRoleKindSupport = "support" + DiscordRoleKindTeam = "team" + DiscordRoleKindCaptain = "captain" + DiscordRoleKindRegistered = "registered" + DiscordRoleKindGoing = "going" + DiscordRoleKindMaybe = "maybe" + DiscordRoleKindNotGoing = "not_going" DiscordRoleActionReconcile = "reconcile" + DiscordRoleActionRSVP = "rsvp" DiscordRoleActionTeardown = "teardown" ) @@ -44,6 +49,12 @@ type DiscordRoleRoster struct { PlayerDiscordIDs map[string]string } +type DiscordRoleRegistration struct { + Event domain.Event + Registrations []domain.RSVP + PlayerDiscordIDs map[string]string +} + type DiscordRoleStore interface { SeedDiscordRoleSyncJobs(context.Context) error ClaimDiscordRoleSyncJob(context.Context) (DiscordRoleSyncJob, error) @@ -57,4 +68,5 @@ type DiscordRoleStore interface { DeleteDiscordRoleAssignment(context.Context, string, string) error GetDiscordRoleRoster(context.Context, string) (DiscordRoleRoster, error) ListActiveDiscordRoleRosters(context.Context) ([]DiscordRoleRoster, error) + GetDiscordRoleRegistration(context.Context, string) (DiscordRoleRegistration, error) } diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index c2eb73a..0befd46 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -215,6 +215,7 @@ func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "event.updated", eventID, out) s.Bus.Publish("events", out) + s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return out, err } @@ -248,6 +249,7 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, "rsvp.set", eventID, out) s.Bus.Publish("event:"+eventID, out) + s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return out, err } @@ -268,6 +270,7 @@ func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, e } _ = s.Store.AppendAudit(ctx, actor.ID, "participant.removed", eventID, map[string]string{"playerId": playerID}) s.Bus.Publish("event:"+eventID, map[string]string{"removedPlayerId": playerID}) + s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) return nil } @@ -313,6 +316,7 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e _ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp}) s.Bus.Publish("event:"+eventID, rsvp) s.Bus.Publish("players", player) + s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionRSVP) } return player, rsvp, err } diff --git a/backend/migrations/012_discord_rsvp_roles.sql b/backend/migrations/012_discord_rsvp_roles.sql new file mode 100644 index 0000000..a91a02a --- /dev/null +++ b/backend/migrations/012_discord_rsvp_roles.sql @@ -0,0 +1,29 @@ +-- +mixmaker Up +ALTER TABLE discord_managed_roles DROP CONSTRAINT IF EXISTS discord_managed_roles_kind_check; +ALTER TABLE discord_managed_roles ADD CONSTRAINT discord_managed_roles_kind_check CHECK ( + kind IN ('tank', 'damage', 'support', 'team', 'captain', 'registered', 'going', 'maybe', 'not_going') +); + +ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check; +ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK ( + action IN ('reconcile', 'rsvp', 'teardown') +); + +-- +mixmaker Down +DELETE FROM discord_role_assignments +WHERE discord_role_id IN ( + SELECT discord_role_id FROM discord_managed_roles + WHERE kind IN ('registered', 'going', 'maybe', 'not_going') +); +DELETE FROM discord_managed_roles +WHERE kind IN ('registered', 'going', 'maybe', 'not_going'); +DELETE FROM discord_role_sync_jobs WHERE action='rsvp'; + +ALTER TABLE discord_managed_roles DROP CONSTRAINT IF EXISTS discord_managed_roles_kind_check; +ALTER TABLE discord_managed_roles ADD CONSTRAINT discord_managed_roles_kind_check CHECK ( + kind IN ('tank', 'damage', 'support', 'team', 'captain') +); +ALTER TABLE discord_role_sync_jobs DROP CONSTRAINT IF EXISTS discord_role_sync_jobs_action_check; +ALTER TABLE discord_role_sync_jobs ADD CONSTRAINT discord_role_sync_jobs_action_check CHECK ( + action IN ('reconcile', 'teardown') +); diff --git a/deployments/coolify.md b/deployments/coolify.md index 8663d2c..3753150 100644 --- a/deployments/coolify.md +++ b/deployments/coolify.md @@ -37,7 +37,7 @@ 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. +For role 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 event-specific RSVP status roles, 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 diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md index 212084c..6def6d9 100644 --- a/memory_bank/active_context.md +++ b/memory_bank/active_context.md @@ -8,6 +8,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад. Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена. Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс. +При любом RSVP тот же worker создаёт event-specific роли «Зарегистрирован», «Идёт», «Возможно», «Не идёт», выдаёт общую роль ответа и ровно один текущий статус, обновляет названия при rename Event и снимает назначения после удаления RSVP. Registration close роли не удаляет; общий event teardown очищает их вместе с team/captain roles. ## Подтверждённые требования @@ -35,6 +36,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r - карточки отменённых событий отображаются красными, завершённых — зелёными, активных — розовыми с меткой LIVE. - новый микс автоматически анонсируется в настроенном Discord-канале со ссылкой на регистрацию; `@everyone` можно отключить при создании. - подтверждённые составы автоматически проецируются в управляемые Discord-роли и очищаются по завершении их жизненного цикла. +- явные RSVP автоматически проецируются в локализованные event-specific Discord-роли ответа и статуса. ## Исходный регламент @@ -81,6 +83,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r ## Ближайший следующий шаг -Проверить миграции `006_event_workflow.sql` и `011_discord_role_sync.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить анонс, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом. +Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql` и `012_discord_rsvp_roles.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, анонс, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом. Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3. diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md index 899f737..dfb1f11 100644 --- a/memory_bank/architecture.md +++ b/memory_bank/architecture.md @@ -122,6 +122,8 @@ Application-слой управляет единым versioned workflow собы Очередь использует `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. +Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли. + ### Капитаны Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит. diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md index 14919dd..dc09e65 100644 --- a/memory_bank/domain_model.md +++ b/memory_bank/domain_model.md @@ -30,6 +30,8 @@ Team/captain roles принадлежат конкретному Event и удаляются при отмене, завершении, удалении или возврате к редактированию roster. Tank/Damage/Support общие для guild: после cleanup их участники пересчитываются по всем оставшимся активным событиям. Guest и пользователь, отсутствующий в guild, не блокируют подтверждение состава и фиксируются как предупреждение интеграции. +Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event. + ### Жизненный цикл события Событие проходит серверно контролируемые состояния: diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md index 3228a6e..4410847 100644 --- a/memory_bank/roadmap.md +++ b/memory_bank/roadmap.md @@ -80,6 +80,7 @@ - статистика игроков и команд; - повторная жеребьёвка с защитой от одинаковых составов; - [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию; +- [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing; - обновление анонса, напоминания, публикация составов и результатов; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; - создание и очистка временных командных голосовых каналов;