diff --git a/backend/internal/adapter/discord/rolesync.go b/backend/internal/adapter/discord/rolesync.go index baa1ce7..71563ab 100644 --- a/backend/internal/adapter/discord/rolesync.go +++ b/backend/internal/adapter/discord/rolesync.go @@ -46,7 +46,8 @@ type RoleManager struct { } type discordRoleResponse struct { - ID string `json:"id"` + ID string `json:"id"` + Position int `json:"position"` } type discordHTTPError struct { @@ -181,10 +182,14 @@ func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, e if err != nil { return nil, err } + registeredRole := existingRole(roles, application.DiscordRoleKindRegistered) roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles) if err != nil { return nil, err } + if err = w.ensureTeamsAboveRegistered(ctx, roles, registeredRole); err != nil { + return nil, err + } desired, warnings, err := w.desiredAssignments(ctx, current, roles) if err != nil { return warnings, err @@ -234,7 +239,7 @@ func (w *RoleWorker) reconcileRSVP(ctx context.Context, eventID string) ([]strin } 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.DiscordRoleKindRegistered, RoleName: names[application.DiscordRoleKindRegistered], Hoist: true}, {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]}, @@ -281,7 +286,7 @@ func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.Roste 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.DiscordRoleKindTeam, RoleName: teamName, Hoist: true}, application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)}, ) } @@ -302,8 +307,8 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic 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 { + if saved.RoleName != role.RoleName || saved.Hoist != role.Hoist { + if err := w.manager.UpdateRole(ctx, saved.DiscordRoleID, role.RoleName, role.Hoist); err != nil { return nil, err } if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil { @@ -311,7 +316,7 @@ func (w *RoleWorker) ensureRoles(ctx context.Context, desired, existing []applic } } } else { - roleID, err := w.manager.CreateRole(ctx, role.RoleName) + roleID, err := w.manager.CreateRole(ctx, role.RoleName, role.Hoist) if err != nil { return nil, err } @@ -486,10 +491,26 @@ func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.Dis return w.store.DeleteDiscordManagedRole(ctx, role) } -func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) { +func (w *RoleWorker) ensureTeamsAboveRegistered(ctx context.Context, roles []application.DiscordManagedRole, registered application.DiscordManagedRole) error { + if registered.DiscordRoleID == "" { + return nil + } + teamRoleIDs := make([]string, 0) + for _, role := range roles { + if role.Kind == application.DiscordRoleKindTeam { + teamRoleIDs = append(teamRoleIDs, role.DiscordRoleID) + } + } + if len(teamRoleIDs) == 0 { + return nil + } + return w.manager.MoveRolesAbove(ctx, teamRoleIDs, registered.DiscordRoleID) +} + +func (m *RoleManager) CreateRole(ctx context.Context, name string, hoist bool) (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, + "name": name, "permissions": "0", "hoist": hoist, "mentionable": false, }, &response); err != nil { return "", err } @@ -499,8 +520,38 @@ func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, erro 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) UpdateRole(ctx context.Context, roleID, name string, hoist bool) error { + return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]any{"name": name, "hoist": hoist}, nil) +} + +func (m *RoleManager) MoveRolesAbove(ctx context.Context, roleIDs []string, anchorRoleID string) error { + var roles []discordRoleResponse + if err := m.request(ctx, http.MethodGet, m.guildPath("/roles"), nil, &roles); err != nil { + return err + } + positions := make(map[string]int, len(roles)) + for _, role := range roles { + positions[role.ID] = role.Position + } + anchorPosition, ok := positions[anchorRoleID] + if !ok { + return nil + } + needsMove := false + for _, roleID := range roleIDs { + if position, found := positions[roleID]; !found || position <= anchorPosition { + needsMove = true + break + } + } + if !needsMove { + return nil + } + payload := make([]map[string]any, 0, len(roleIDs)) + for index, roleID := range roleIDs { + payload = append(payload, map[string]any{"id": roleID, "position": anchorPosition + index + 1}) + } + return m.request(ctx, http.MethodPatch, m.guildPath("/roles"), payload, nil) } func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error { @@ -575,6 +626,15 @@ func managedRoleKey(role application.DiscordManagedRole) string { return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00") } +func existingRole(roles []application.DiscordManagedRole, kind string) application.DiscordManagedRole { + for _, role := range roles { + if role.Kind == kind { + return role + } + } + return application.DiscordManagedRole{} +} + func roleKind(role domain.Role) string { switch role { case domain.Tank: diff --git a/backend/internal/adapter/discord/rolesync_test.go b/backend/internal/adapter/discord/rolesync_test.go index 525580a..8b4b92c 100644 --- a/backend/internal/adapter/discord/rolesync_test.go +++ b/backend/internal/adapter/discord/rolesync_test.go @@ -125,6 +125,8 @@ func (s *roleStoreFake) GetDiscordRoleRegistration(_ context.Context, eventID st func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { var mu sync.Mutex roleNames := make(map[string]string) + roleHoists := make(map[string]bool) + rolePositions := map[string]int{"role-rsvp": 1} methods := make([]string, 0) nextID := 0 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { @@ -137,15 +139,38 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { nextID++ id := "role-" + string(rune('0'+nextID)) roleNames[id], _ = payload["name"].(string) + roleHoists[id], _ = payload["hoist"].(bool) + rolePositions[id] = 0 response.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(response).Encode(map[string]string{"id": id}) return } + if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/roles") { + roles := []map[string]any{{"id": "role-rsvp", "position": rolePositions["role-rsvp"]}} + for id := range roleNames { + roles = append(roles, map[string]any{"id": id, "position": rolePositions[id]}) + } + response.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(response).Encode(roles) + return + } if request.Method == http.MethodPatch { - var payload map[string]string - _ = json.NewDecoder(request.Body).Decode(&payload) parts := strings.Split(request.URL.Path, "/") - roleNames[parts[len(parts)-1]] = payload["name"] + roleID := parts[len(parts)-1] + if roleID == "roles" { + var payload []struct { + ID string `json:"id"` + Position int `json:"position"` + } + _ = json.NewDecoder(request.Body).Decode(&payload) + for _, item := range payload { + rolePositions[item.ID] = item.Position + } + } else { + var payload map[string]any + _ = json.NewDecoder(request.Body).Decode(&payload) + roleNames[roleID], _ = payload["name"].(string) + } } response.WriteHeader(http.StatusNoContent) })) @@ -157,7 +182,7 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { store.active = []application.DiscordRoleRoster{item} rsvpRole := application.DiscordManagedRole{ Scope: application.DiscordRoleScopeEvent, EventID: "event-1", - Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", + Kind: application.DiscordRoleKindRegistered, DiscordRoleID: "role-rsvp", RoleName: "Зарегистрирован: Mix", Hoist: true, } store.roles[managedRoleKey(rsvpRole)] = rsvpRole store.jobs = []application.DiscordRoleSyncJob{{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile}} @@ -179,6 +204,14 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { if strings.Join(names, ",") != strings.Join(expectedNames, ",") { t.Fatalf("unexpected role names: %v", names) } + for id, name := range roleNames { + if roleHoists[id] != (name == "Alpha") { + t.Fatalf("unexpected hoist for %s: %v", name, roleHoists[id]) + } + if name == "Alpha" && rolePositions[id] <= rolePositions["role-rsvp"] { + t.Fatalf("team role was not moved above registered role: team=%d registered=%d", rolePositions[id], rolePositions["role-rsvp"]) + } + } if got := assignmentCount(store.assignments); got != 11 { t.Fatalf("expected 11 managed assignments, got %d", got) } @@ -191,8 +224,10 @@ func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) { if _, err = worker.ProcessNext(context.Background()); err != nil { t.Fatal(err) } - if len(methods) != firstRequestCount { - t.Fatalf("idempotent reconcile made %d extra Discord calls", len(methods)-firstRequestCount) + for _, method := range methods[firstRequestCount:] { + if !strings.HasPrefix(method, http.MethodGet+" ") { + t.Fatalf("idempotent reconcile made a mutating Discord call: %s", method) + } } renamed := testDiscordRoster("Night Owls") @@ -236,6 +271,13 @@ func TestRoleWorkerReconcilesRSVPStatusAndEventRename(t *testing.T) { patches := 0 server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodPost { + var payload map[string]any + _ = json.NewDecoder(request.Body).Decode(&payload) + name, _ := payload["name"].(string) + hoist, _ := payload["hoist"].(bool) + if hoist != strings.HasPrefix(name, "Зарегистрирован:") { + t.Errorf("unexpected RSVP role hoist: name=%q hoist=%v", name, hoist) + } nextID++ _ = json.NewEncoder(response).Encode(map[string]string{"id": "rsvp-role-" + string(rune('0'+nextID))}) return diff --git a/backend/internal/adapter/postgres/discord_roles.go b/backend/internal/adapter/postgres/discord_roles.go index f64843c..44f73c2 100644 --- a/backend/internal/adapter/postgres/discord_roles.go +++ b/backend/internal/adapter/postgres/discord_roles.go @@ -78,7 +78,7 @@ func (s *Store) RetryDiscordRoleSyncJob(ctx context.Context, jobID int64, messag } func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([]application.DiscordManagedRole, error) { - rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name + rows, err := s.pool.Query(ctx, `SELECT scope,event_id,team_id,kind,discord_role_id,role_name,hoist FROM discord_managed_roles WHERE scope='global' OR event_id=$1 ORDER BY scope,event_id,team_id,kind`, eventID) @@ -89,7 +89,7 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([] roles := make([]application.DiscordManagedRole, 0) for rows.Next() { var role application.DiscordManagedRole - if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName); err != nil { + if err = rows.Scan(&role.Scope, &role.EventID, &role.TeamID, &role.Kind, &role.DiscordRoleID, &role.RoleName, &role.Hoist); err != nil { return nil, err } roles = append(roles, role) @@ -98,11 +98,11 @@ func (s *Store) ListDiscordManagedRoles(ctx context.Context, eventID string) ([] } func (s *Store) UpsertDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error { - _, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name) - VALUES($1,$2,$3,$4,$5,$6) + _, err := s.pool.Exec(ctx, `INSERT INTO discord_managed_roles(scope,event_id,team_id,kind,discord_role_id,role_name,hoist) + VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(scope,event_id,team_id,kind) DO UPDATE - SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,updated_at=now()`, - role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName) + SET discord_role_id=excluded.discord_role_id,role_name=excluded.role_name,hoist=excluded.hoist,updated_at=now()`, + role.Scope, role.EventID, role.TeamID, role.Kind, role.DiscordRoleID, role.RoleName, role.Hoist) return err } diff --git a/backend/internal/application/discord_roles.go b/backend/internal/application/discord_roles.go index 8e8b95a..56f1cbb 100644 --- a/backend/internal/application/discord_roles.go +++ b/backend/internal/application/discord_roles.go @@ -33,6 +33,7 @@ type DiscordManagedRole struct { Kind string DiscordRoleID string RoleName string + Hoist bool } type DiscordRoleSyncJob struct { diff --git a/backend/migrations/013_discord_role_hoist.sql b/backend/migrations/013_discord_role_hoist.sql new file mode 100644 index 0000000..bc01c61 --- /dev/null +++ b/backend/migrations/013_discord_role_hoist.sql @@ -0,0 +1,7 @@ +-- +mixmaker Up +ALTER TABLE discord_managed_roles + ADD COLUMN hoist boolean NOT NULL DEFAULT false; + +-- +mixmaker Down +ALTER TABLE discord_managed_roles + DROP COLUMN IF EXISTS hoist; diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md index 6def6d9..8de7854 100644 --- a/memory_bank/active_context.md +++ b/memory_bank/active_context.md @@ -9,6 +9,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r Добавлен 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. +В Discord member list отдельными hoisted-группами отображаются роли команд и общая роль регистрации. Team-группы располагаются выше registered-группы; Captain, slot и конкретный RSVP-статус остаются негруппирующими служебными ролями. ## Подтверждённые требования @@ -83,6 +84,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r ## Ближайший следующий шаг -Проверить миграции `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-миксом. +Проверить миграции `006_event_workflow.sql`, `011_discord_role_sync.sql`, `012_discord_rsvp_roles.sql` и `013_discord_role_hoist.sql` с реальной PostgreSQL (`TEST_DATABASE_URL`), затем на staging проверить RSVP transitions, member-list grouping, подтверждение roster, rename/emergency substitution и cleanup Discord-ролей перед первым production-миксом. Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3. diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md index dfb1f11..780aa44 100644 --- a/memory_bank/architecture.md +++ b/memory_bank/architecture.md @@ -124,6 +124,8 @@ Application-слой управляет единым versioned workflow собы Тот же worker ведёт event-specific RSVP-роли независимо от наличия roster: `registered` обозначает явный ответ, а `going` / `maybe` / `not_going` взаимоисключающи и следуют текущей записи RSVP. Изменение RSVP, удаление участника и переименование Event ставят отдельный job. Roster reconcile и RSVP reconcile владеют разными role kinds и не удаляют роли друг друга; общий teardown очищает все event-specific роли. +Discord hoist включён только для team roles и общей `registered`-роли. Team roles автоматически поднимаются выше `registered`, поэтому после формирования состава игрок отображается в группе команды, а ещё не распределённые участники — в общей группе регистрации. Captain, Tank/Damage/Support и конкретные RSVP status roles остаются служебными и не создают отдельные группы участников. + ### Капитаны Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит. diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md index dc09e65..fc7c5c7 100644 --- a/memory_bank/domain_model.md +++ b/memory_bank/domain_model.md @@ -32,6 +32,8 @@ Team/captain roles принадлежат конкретному Event и уда Для каждого Event бот также поддерживает четыре временные RSVP-роли: общую `Зарегистрирован: {Event.Name}` для любого явного ответа и взаимоисключающие `Идёт`, `Возможно`, `Не идёт`. Изменение EventRegistration атомарно меняет desired status-role; удаление регистрации снимает все RSVP-назначения. Эти роли сохраняются после закрытия регистрации и удаляются вместе с другими event-specific ролями при завершении, отмене или удалении Event. +В списке участников Discord отдельными группами отображаются только принадлежность к Team и общая регистрация. Роль команды располагается выше registration-role: распределённый игрок показывается под своей командой, а нераспределённый ответивший — в группе регистрации. Ролевой slot, конкретный RSVP-статус и Captain не влияют на группировку. + ### Жизненный цикл события Событие проходит серверно контролируемые состояния: diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md index 4410847..a7719ee 100644 --- a/memory_bank/roadmap.md +++ b/memory_bank/roadmap.md @@ -83,6 +83,7 @@ - [x] event-specific Discord-роли ответа и статусов Going/Maybe/NotGoing; - обновление анонса, напоминания, публикация составов и результатов; - [x] идемпотентная синхронизация временных Discord team/captain и Tank/Damage/Support ролей; +- [x] отдельные member-list группы для команд и зарегистрированных участников; - создание и очистка временных командных голосовых каналов; - импорт данных и резервное копирование.