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 ролей; - создание и очистка временных командных голосовых каналов;