Enhance Discord role management by adding hoist functionality
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new `hoist` attribute for Discord roles, allowing team roles and the registered role to be displayed in separate groups within the Discord member list. The `RoleWorker` has been updated to ensure team roles are positioned above the registered role, improving visibility and organization. Database schema changes have been made to support the new `hoist` field, and corresponding updates have been implemented in the service layer and tests to validate the new behavior.
This commit is contained in:
2026-07-19 11:52:57 +03:00
parent 966c81ba12
commit e5646ba33d
9 changed files with 139 additions and 23 deletions

View File

@@ -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: