Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
This commit is contained in:
@@ -116,16 +116,16 @@ func New(config Config) (*Announcer, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
err := a.announceEventCreated(ctx, event)
|
||||
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
|
||||
err := a.announceEventCreated(ctx, event, pingEveryone)
|
||||
if err != nil {
|
||||
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
|
||||
payload, err := json.Marshal(a.createdMessage(event))
|
||||
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event, pingEveryone bool) error {
|
||||
payload, err := json.Marshal(a.createdMessage(event, pingEveryone))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Discord message: %w", err)
|
||||
}
|
||||
@@ -149,11 +149,10 @@ func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
|
||||
func (a *Announcer) createdMessage(event domain.Event, pingEveryone bool) messagePayload {
|
||||
text := announcementTranslations(a.locale)
|
||||
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
|
||||
return messagePayload{
|
||||
Content: "@everyone",
|
||||
message := messagePayload{
|
||||
Embeds: []embed{{
|
||||
Title: truncate(text.titlePrefix+event.Name, 256),
|
||||
Description: truncate(event.Description, 4096),
|
||||
@@ -173,8 +172,13 @@ func (a *Announcer) createdMessage(event domain.Event) messagePayload {
|
||||
URL: eventURL,
|
||||
}},
|
||||
}},
|
||||
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
|
||||
AllowedMentions: allowedMentions{Parse: []string{}},
|
||||
}
|
||||
if pingEveryone {
|
||||
message.Content = "@everyone"
|
||||
message.AllowedMentions.Parse = []string{"everyone"}
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func announcementTranslations(locale string) translations {
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestAnnounceEventCreated(t *testing.T) {
|
||||
RegistrationDeadline: start.Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), event, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if received.Content != "@everyone" {
|
||||
@@ -75,6 +75,26 @@ func TestAnnounceEventCreated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatedMessageCanOmitEveryonePing(t *testing.T) {
|
||||
announcer, err := New(Config{
|
||||
BotToken: "secret-token",
|
||||
ChannelID: "channel-123",
|
||||
PublicURL: "https://mix.example.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := domain.Event{ID: "event-456", Name: "Quiet mix"}
|
||||
|
||||
message := announcer.createdMessage(event, false)
|
||||
if message.Content != "" || len(message.AllowedMentions.Parse) != 0 {
|
||||
t.Fatalf("unexpected mention in quiet announcement: content=%q mentions=%+v", message.Content, message.AllowedMentions)
|
||||
}
|
||||
if len(message.Embeds) != 1 || len(message.Components) != 1 {
|
||||
t.Fatalf("quiet announcement must retain embed and registration button: %+v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
|
||||
@@ -91,7 +111,7 @@ func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
|
||||
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}, true); err == nil {
|
||||
t.Fatal("expected Discord error")
|
||||
}
|
||||
}
|
||||
|
||||
518
backend/internal/adapter/discord/rolesync.go
Normal file
518
backend/internal/adapter/discord/rolesync.go
Normal file
@@ -0,0 +1,518 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type RoleSyncConfig struct {
|
||||
BotToken string
|
||||
GuildID string
|
||||
APIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
PollInterval time.Duration
|
||||
JobTimeout time.Duration
|
||||
}
|
||||
|
||||
type RoleWorker struct {
|
||||
store application.DiscordRoleStore
|
||||
manager *RoleManager
|
||||
pollInterval time.Duration
|
||||
jobTimeout time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type RoleManager struct {
|
||||
botToken string
|
||||
guildID string
|
||||
apiBaseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type discordRoleResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type discordHTTPError struct {
|
||||
StatusCode int
|
||||
Status string
|
||||
Body string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *discordHTTPError) Error() string {
|
||||
return fmt.Sprintf("Discord returned %s: %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
func NewRoleWorker(store application.DiscordRoleStore, config RoleSyncConfig) (*RoleWorker, error) {
|
||||
if store == nil {
|
||||
return nil, errors.New("Discord role store is required")
|
||||
}
|
||||
manager, err := newRoleManager(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pollInterval := config.PollInterval
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = 2 * time.Second
|
||||
}
|
||||
jobTimeout := config.JobTimeout
|
||||
if jobTimeout <= 0 {
|
||||
jobTimeout = 30 * time.Second
|
||||
}
|
||||
return &RoleWorker{
|
||||
store: store,
|
||||
manager: manager,
|
||||
pollInterval: pollInterval,
|
||||
jobTimeout: jobTimeout,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newRoleManager(config RoleSyncConfig) (*RoleManager, error) {
|
||||
if strings.TrimSpace(config.BotToken) == "" {
|
||||
return nil, errors.New("discord bot token is required for role sync")
|
||||
}
|
||||
if strings.TrimSpace(config.GuildID) == "" {
|
||||
return nil, errors.New("discord guild ID is required for role sync")
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = defaultAPIBaseURL
|
||||
}
|
||||
httpClient := config.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &RoleManager{
|
||||
botToken: config.BotToken,
|
||||
guildID: config.GuildID,
|
||||
apiBaseURL: apiBaseURL,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) Run(ctx context.Context) {
|
||||
if err := w.store.SeedDiscordRoleSyncJobs(ctx); err != nil {
|
||||
slog.Error("Could not seed Discord role sync jobs", "error", err)
|
||||
}
|
||||
ticker := time.NewTicker(w.pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for {
|
||||
processed, err := w.ProcessNext(ctx)
|
||||
if err != nil {
|
||||
slog.Error("Discord role sync worker failed", "error", err)
|
||||
}
|
||||
if !processed {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RoleWorker) ProcessNext(ctx context.Context) (bool, error) {
|
||||
job, err := w.store.ClaimDiscordRoleSyncJob(ctx)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
jobCtx, cancel := context.WithTimeout(ctx, w.jobTimeout)
|
||||
defer cancel()
|
||||
var warnings []string
|
||||
if job.Action == application.DiscordRoleActionTeardown {
|
||||
warnings, err = w.teardown(jobCtx, job)
|
||||
} else {
|
||||
warnings, err = w.reconcile(jobCtx, job.EventID)
|
||||
}
|
||||
if err == nil {
|
||||
return true, w.store.CompleteDiscordRoleSyncJob(ctx, job.ID, strings.Join(warnings, "; "))
|
||||
}
|
||||
delay := retryDelay(job.Attempts, err)
|
||||
if retryErr := w.store.RetryDiscordRoleSyncJob(ctx, job.ID, err.Error(), w.now().Add(delay)); retryErr != nil {
|
||||
return true, fmt.Errorf("role sync failed: %v; schedule retry: %w", err, retryErr)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) reconcile(ctx context.Context, eventID string) ([]string, error) {
|
||||
current, err := w.store.GetDiscordRoleRoster(ctx, eventID)
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
return w.teardown(ctx, application.DiscordRoleSyncJob{EventID: eventID})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles, err := w.store.ListDiscordManagedRoles(ctx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roles, err = w.ensureDesiredRoles(ctx, current.Roster, roles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
desired, warnings, err := w.desiredAssignments(ctx, current, roles)
|
||||
if err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
for _, role := range roles {
|
||||
if role.Scope == application.DiscordRoleScopeEvent && role.EventID != eventID {
|
||||
continue
|
||||
}
|
||||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||||
warnings = append(warnings, roleWarnings...)
|
||||
if syncErr != nil {
|
||||
return warnings, syncErr
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) ensureDesiredRoles(ctx context.Context, roster domain.RosterDraft, existing []application.DiscordManagedRole) ([]application.DiscordManagedRole, error) {
|
||||
desired := []application.DiscordManagedRole{
|
||||
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindTank, RoleName: "Tank"},
|
||||
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindDamage, RoleName: "Damage"},
|
||||
{Scope: application.DiscordRoleScopeGlobal, Kind: application.DiscordRoleKindSupport, RoleName: "Support"},
|
||||
}
|
||||
for _, team := range roster.Teams {
|
||||
teamName := truncate(strings.TrimSpace(team.Name), 100)
|
||||
desired = append(desired,
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam, RoleName: teamName},
|
||||
application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain, RoleName: truncate(teamName+" Captain", 100)},
|
||||
)
|
||||
}
|
||||
existingByKey := make(map[string]application.DiscordManagedRole, len(existing))
|
||||
for _, role := range existing {
|
||||
existingByKey[managedRoleKey(role)] = role
|
||||
}
|
||||
desiredKeys := make(map[string]bool, len(desired))
|
||||
result := make([]application.DiscordManagedRole, 0, len(desired))
|
||||
for _, role := range desired {
|
||||
key := managedRoleKey(role)
|
||||
desiredKeys[key] = true
|
||||
if saved, ok := existingByKey[key]; ok {
|
||||
role.DiscordRoleID = saved.DiscordRoleID
|
||||
if saved.RoleName != role.RoleName {
|
||||
if err := w.manager.RenameRole(ctx, saved.DiscordRoleID, role.RoleName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
roleID, err := w.manager.CreateRole(ctx, role.RoleName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role.DiscordRoleID = roleID
|
||||
if err = w.store.UpsertDiscordManagedRole(ctx, role); err != nil {
|
||||
_ = w.manager.DeleteRole(ctx, roleID)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
result = append(result, role)
|
||||
}
|
||||
for _, role := range existing {
|
||||
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != roster.EventID || desiredKeys[managedRoleKey(role)] {
|
||||
continue
|
||||
}
|
||||
if err := w.deleteManagedRole(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) desiredAssignments(ctx context.Context, current application.DiscordRoleRoster, roles []application.DiscordManagedRole) (map[string]map[string]bool, []string, error) {
|
||||
desired := make(map[string]map[string]bool)
|
||||
byKey := make(map[string]application.DiscordManagedRole, len(roles))
|
||||
for _, role := range roles {
|
||||
byKey[managedRoleKey(role)] = role
|
||||
desired[role.DiscordRoleID] = make(map[string]bool)
|
||||
}
|
||||
warnings := appendRosterAssignments(desired, byKey, current, true)
|
||||
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
|
||||
if err != nil {
|
||||
return nil, warnings, err
|
||||
}
|
||||
for _, roster := range active {
|
||||
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
|
||||
}
|
||||
return desired, uniqueStrings(warnings), nil
|
||||
}
|
||||
|
||||
func appendRosterAssignments(desired map[string]map[string]bool, roles map[string]application.DiscordManagedRole, item application.DiscordRoleRoster, includeEventRoles bool) []string {
|
||||
warnings := make([]string, 0)
|
||||
for _, team := range item.Roster.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
if slot.PlayerID == "" {
|
||||
continue
|
||||
}
|
||||
discordID := item.PlayerDiscordIDs[slot.PlayerID]
|
||||
if discordID == "" {
|
||||
warnings = append(warnings, "player "+slot.PlayerID+" has no linked Discord account")
|
||||
continue
|
||||
}
|
||||
globalRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeGlobal, Kind: roleKind(slot.Role)})]
|
||||
if globalRole.DiscordRoleID != "" {
|
||||
desired[globalRole.DiscordRoleID][discordID] = true
|
||||
}
|
||||
if !includeEventRoles {
|
||||
continue
|
||||
}
|
||||
teamRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindTeam})]
|
||||
if teamRole.DiscordRoleID != "" {
|
||||
desired[teamRole.DiscordRoleID][discordID] = true
|
||||
}
|
||||
if slot.PlayerID == team.CaptainPlayerID {
|
||||
captainRole := roles[managedRoleKey(application.DiscordManagedRole{Scope: application.DiscordRoleScopeEvent, EventID: item.Roster.EventID, TeamID: team.ID, Kind: application.DiscordRoleKindCaptain})]
|
||||
if captainRole.DiscordRoleID != "" {
|
||||
desired[captainRole.DiscordRoleID][discordID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
func (w *RoleWorker) syncAssignments(ctx context.Context, roleID string, desired map[string]bool) ([]string, error) {
|
||||
current, err := w.store.ListDiscordRoleAssignments(ctx, roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentSet := make(map[string]bool, len(current))
|
||||
for _, userID := range current {
|
||||
currentSet[userID] = true
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
for userID := range desired {
|
||||
if currentSet[userID] {
|
||||
continue
|
||||
}
|
||||
if err = w.manager.AddMemberRole(ctx, userID, roleID); err != nil {
|
||||
var httpErr *discordHTTPError
|
||||
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
|
||||
warnings = append(warnings, "Discord member "+userID+" is not in the guild")
|
||||
continue
|
||||
}
|
||||
return warnings, err
|
||||
}
|
||||
if err = w.store.UpsertDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
for userID := range currentSet {
|
||||
if desired[userID] {
|
||||
continue
|
||||
}
|
||||
if err = w.manager.RemoveMemberRole(ctx, userID, roleID); err != nil && !isDiscordNotFound(err) {
|
||||
return warnings, err
|
||||
}
|
||||
if err = w.store.DeleteDiscordRoleAssignment(ctx, roleID, userID); err != nil {
|
||||
return warnings, err
|
||||
}
|
||||
}
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) teardown(ctx context.Context, job application.DiscordRoleSyncJob) ([]string, error) {
|
||||
roles, err := w.store.ListDiscordManagedRoles(ctx, job.EventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted := make(map[string]bool)
|
||||
for _, role := range roles {
|
||||
if role.Scope != application.DiscordRoleScopeEvent || role.EventID != job.EventID {
|
||||
continue
|
||||
}
|
||||
if err = w.deleteManagedRole(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted[role.DiscordRoleID] = true
|
||||
}
|
||||
for _, roleID := range job.RoleSnapshot {
|
||||
if deleted[roleID] {
|
||||
continue
|
||||
}
|
||||
if err = w.manager.DeleteRole(ctx, roleID); err != nil && !isDiscordNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
globalRoles := make([]application.DiscordManagedRole, 0)
|
||||
for _, role := range roles {
|
||||
if role.Scope == application.DiscordRoleScopeGlobal {
|
||||
globalRoles = append(globalRoles, role)
|
||||
}
|
||||
}
|
||||
active, err := w.store.ListActiveDiscordRoleRosters(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
desired := make(map[string]map[string]bool)
|
||||
byKey := make(map[string]application.DiscordManagedRole)
|
||||
for _, role := range globalRoles {
|
||||
desired[role.DiscordRoleID] = make(map[string]bool)
|
||||
byKey[managedRoleKey(role)] = role
|
||||
}
|
||||
warnings := make([]string, 0)
|
||||
for _, roster := range active {
|
||||
warnings = append(warnings, appendRosterAssignments(desired, byKey, roster, false)...)
|
||||
}
|
||||
for _, role := range globalRoles {
|
||||
roleWarnings, syncErr := w.syncAssignments(ctx, role.DiscordRoleID, desired[role.DiscordRoleID])
|
||||
warnings = append(warnings, roleWarnings...)
|
||||
if syncErr != nil {
|
||||
return warnings, syncErr
|
||||
}
|
||||
}
|
||||
return uniqueStrings(warnings), nil
|
||||
}
|
||||
|
||||
func (w *RoleWorker) deleteManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
|
||||
if err := w.manager.DeleteRole(ctx, role.DiscordRoleID); err != nil && !isDiscordNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return w.store.DeleteDiscordManagedRole(ctx, role)
|
||||
}
|
||||
|
||||
func (m *RoleManager) CreateRole(ctx context.Context, name string) (string, error) {
|
||||
var response discordRoleResponse
|
||||
if err := m.request(ctx, http.MethodPost, m.guildPath("/roles"), map[string]any{
|
||||
"name": name, "permissions": "0", "hoist": false, "mentionable": false,
|
||||
}, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.ID == "" {
|
||||
return "", errors.New("Discord create role response has no ID")
|
||||
}
|
||||
return response.ID, nil
|
||||
}
|
||||
|
||||
func (m *RoleManager) RenameRole(ctx context.Context, roleID, name string) error {
|
||||
return m.request(ctx, http.MethodPatch, m.guildPath("/roles/"+url.PathEscape(roleID)), map[string]string{"name": name}, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) DeleteRole(ctx context.Context, roleID string) error {
|
||||
return m.request(ctx, http.MethodDelete, m.guildPath("/roles/"+url.PathEscape(roleID)), nil, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) AddMemberRole(ctx context.Context, userID, roleID string) error {
|
||||
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
|
||||
return m.request(ctx, http.MethodPut, m.guildPath(path), nil, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) RemoveMemberRole(ctx context.Context, userID, roleID string) error {
|
||||
path := "/members/" + url.PathEscape(userID) + "/roles/" + url.PathEscape(roleID)
|
||||
return m.request(ctx, http.MethodDelete, m.guildPath(path), nil, nil)
|
||||
}
|
||||
|
||||
func (m *RoleManager) guildPath(path string) string {
|
||||
return m.apiBaseURL + "/guilds/" + url.PathEscape(m.guildID) + path
|
||||
}
|
||||
|
||||
func (m *RoleManager) request(ctx context.Context, method, endpoint string, payload any, result any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bot "+m.botToken)
|
||||
if payload != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
httpErr := &discordHTTPError{StatusCode: response.StatusCode, Status: response.Status, Body: strings.TrimSpace(string(responseBody))}
|
||||
if seconds, parseErr := strconv.ParseFloat(response.Header.Get("Retry-After"), 64); parseErr == nil {
|
||||
httpErr.RetryAfter = time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
return httpErr
|
||||
}
|
||||
if result != nil {
|
||||
return json.NewDecoder(response.Body).Decode(result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func retryDelay(attempts int, err error) time.Duration {
|
||||
var httpErr *discordHTTPError
|
||||
if errors.As(err, &httpErr) && httpErr.RetryAfter > 0 {
|
||||
return httpErr.RetryAfter
|
||||
}
|
||||
if attempts < 1 {
|
||||
attempts = 1
|
||||
}
|
||||
if attempts > 6 {
|
||||
attempts = 6
|
||||
}
|
||||
return time.Duration(1<<uint(attempts-1)) * time.Second
|
||||
}
|
||||
|
||||
func managedRoleKey(role application.DiscordManagedRole) string {
|
||||
return strings.Join([]string{role.Scope, role.EventID, role.TeamID, role.Kind}, "\x00")
|
||||
}
|
||||
|
||||
func roleKind(role domain.Role) string {
|
||||
switch role {
|
||||
case domain.Tank:
|
||||
return application.DiscordRoleKindTank
|
||||
case domain.Damage:
|
||||
return application.DiscordRoleKindDamage
|
||||
case domain.Support:
|
||||
return application.DiscordRoleKindSupport
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isDiscordNotFound(err error) bool {
|
||||
var httpErr *discordHTTPError
|
||||
return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
384
backend/internal/adapter/discord/rolesync_test.go
Normal file
384
backend/internal/adapter/discord/rolesync_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func newRoleStoreFake() *roleStoreFake {
|
||||
return &roleStoreFake{
|
||||
roles: make(map[string]application.DiscordManagedRole),
|
||||
assignments: make(map[string]map[string]bool),
|
||||
rosters: make(map[string]application.DiscordRoleRoster),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) SeedDiscordRoleSyncJobs(context.Context) error { return nil }
|
||||
|
||||
func (s *roleStoreFake) ClaimDiscordRoleSyncJob(context.Context) (application.DiscordRoleSyncJob, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.jobs) == 0 {
|
||||
return application.DiscordRoleSyncJob{}, domain.ErrNotFound
|
||||
}
|
||||
job := s.jobs[0]
|
||||
s.jobs = s.jobs[1:]
|
||||
job.Attempts++
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) CompleteDiscordRoleSyncJob(_ context.Context, _ int64, warning string) error {
|
||||
s.warnings = append(s.warnings, warning)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) RetryDiscordRoleSyncJob(_ context.Context, _ int64, message string, _ time.Time) error {
|
||||
s.warnings = append(s.warnings, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) ListDiscordManagedRoles(_ context.Context, eventID string) ([]application.DiscordManagedRole, error) {
|
||||
out := make([]application.DiscordManagedRole, 0)
|
||||
for _, role := range s.roles {
|
||||
if role.Scope == application.DiscordRoleScopeGlobal || role.EventID == eventID {
|
||||
out = append(out, role)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) UpsertDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
|
||||
s.roles[managedRoleKey(role)] = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) DeleteDiscordManagedRole(_ context.Context, role application.DiscordManagedRole) error {
|
||||
delete(s.assignments, role.DiscordRoleID)
|
||||
delete(s.roles, managedRoleKey(role))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) ListDiscordRoleAssignments(_ context.Context, roleID string) ([]string, error) {
|
||||
out := make([]string, 0)
|
||||
for userID := range s.assignments[roleID] {
|
||||
out = append(out, userID)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) UpsertDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
|
||||
if s.assignments[roleID] == nil {
|
||||
s.assignments[roleID] = make(map[string]bool)
|
||||
}
|
||||
s.assignments[roleID][userID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) DeleteDiscordRoleAssignment(_ context.Context, roleID, userID string) error {
|
||||
delete(s.assignments[roleID], userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) GetDiscordRoleRoster(_ context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
||||
roster, ok := s.rosters[eventID]
|
||||
if !ok {
|
||||
return roster, domain.ErrNotFound
|
||||
}
|
||||
return roster, nil
|
||||
}
|
||||
|
||||
func (s *roleStoreFake) ListActiveDiscordRoleRosters(context.Context) ([]application.DiscordRoleRoster, error) {
|
||||
return s.active, nil
|
||||
}
|
||||
|
||||
func TestRoleWorkerReconcilesIdempotentlyAndRenames(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
roleNames := make(map[string]string)
|
||||
methods := make([]string, 0)
|
||||
nextID := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
methods = append(methods, request.Method+" "+request.URL.Path)
|
||||
if request.Method == http.MethodPost && strings.HasSuffix(request.URL.Path, "/roles") {
|
||||
var payload map[string]any
|
||||
_ = json.NewDecoder(request.Body).Decode(&payload)
|
||||
nextID++
|
||||
id := "role-" + string(rune('0'+nextID))
|
||||
roleNames[id], _ = payload["name"].(string)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(response).Encode(map[string]string{"id": id})
|
||||
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"]
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
store := newRoleStoreFake()
|
||||
item := testDiscordRoster("Alpha")
|
||||
store.rosters["event-1"] = item
|
||||
store.active = []application.DiscordRoleRoster{item}
|
||||
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(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if processed, err := worker.ProcessNext(context.Background()); err != nil || !processed {
|
||||
t.Fatalf("first reconcile: processed=%v err=%v", processed, err)
|
||||
}
|
||||
names := make([]string, 0, len(roleNames))
|
||||
for _, name := range roleNames {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
expectedNames := []string{"Alpha", "Alpha Captain", "Damage", "Support", "Tank"}
|
||||
if strings.Join(names, ",") != strings.Join(expectedNames, ",") {
|
||||
t.Fatalf("unexpected role names: %v", names)
|
||||
}
|
||||
if got := assignmentCount(store.assignments); got != 11 {
|
||||
t.Fatalf("expected 11 managed assignments, got %d", got)
|
||||
}
|
||||
|
||||
firstRequestCount := len(methods)
|
||||
store.jobs = []application.DiscordRoleSyncJob{{ID: 2, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
|
||||
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)
|
||||
}
|
||||
|
||||
renamed := testDiscordRoster("Night Owls")
|
||||
store.rosters["event-1"] = renamed
|
||||
store.active = []application.DiscordRoleRoster{renamed}
|
||||
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
patches := 0
|
||||
for _, method := range methods[firstRequestCount:] {
|
||||
if strings.HasPrefix(method, http.MethodPatch+" ") {
|
||||
patches++
|
||||
}
|
||||
}
|
||||
if patches != 2 {
|
||||
t.Fatalf("expected team and captain role rename, got %d PATCH calls", patches)
|
||||
}
|
||||
|
||||
roleChanged := testDiscordRoster("Night Owls")
|
||||
roleChanged.Roster.Teams[0].Slots[0].Role = domain.Damage
|
||||
roleChanged.Roster.Teams[0].Slots[1].Role = domain.Tank
|
||||
store.rosters["event-1"] = roleChanged
|
||||
store.active = []application.DiscordRoleRoster{roleChanged}
|
||||
store.jobs = []application.DiscordRoleSyncJob{{ID: 4, EventID: "event-1", Action: application.DiscordRoleActionReconcile}}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tankRoleID := managedRoleID(store.roles, application.DiscordRoleKindTank)
|
||||
damageRoleID := managedRoleID(store.roles, application.DiscordRoleKindDamage)
|
||||
if !store.assignments[tankRoleID]["user-2"] || store.assignments[tankRoleID]["user-1"] {
|
||||
t.Fatalf("tank assignment did not follow changed slot: %v", store.assignments[tankRoleID])
|
||||
}
|
||||
if !store.assignments[damageRoleID]["user-1"] || store.assignments[damageRoleID]["user-2"] {
|
||||
t.Fatalf("damage assignment did not follow changed slot: %v", store.assignments[damageRoleID])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleWorkerStoresMissingGuildMemberWarning(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodPost {
|
||||
_ = json.NewEncoder(response).Encode(map[string]string{"id": strings.ReplaceAll(request.URL.Path, "/", "-") + time.Now().String()})
|
||||
return
|
||||
}
|
||||
if request.Method == http.MethodPut && strings.Contains(request.URL.Path, "/members/missing/") {
|
||||
http.Error(response, `{"message":"Unknown Member"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
store := newRoleStoreFake()
|
||||
item := testDiscordRoster("Alpha")
|
||||
item.PlayerDiscordIDs["player-1"] = "missing"
|
||||
store.rosters["event-1"] = item
|
||||
store.active = []application.DiscordRoleRoster{item}
|
||||
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()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(store.warnings) != 1 || !strings.Contains(store.warnings[0], "not in the guild") {
|
||||
t.Fatalf("missing member warning was not stored: %v", store.warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleWorkerTeardownPreservesOtherEventGlobalAssignments(t *testing.T) {
|
||||
nextID := 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": "role-" + string(rune('0'+nextID))})
|
||||
return
|
||||
}
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
store := newRoleStoreFake()
|
||||
first := testDiscordRoster("Alpha")
|
||||
second := testDiscordRoster("Bravo")
|
||||
second.Roster.EventID = "event-2"
|
||||
second.Roster.Teams[0].EventID = "event-2"
|
||||
second.Roster.Teams[0].ID = "team-2"
|
||||
second.PlayerDiscordIDs = make(map[string]string)
|
||||
for index := range second.Roster.Teams[0].Slots {
|
||||
playerID := "other-" + string(rune('1'+index))
|
||||
second.Roster.Teams[0].Slots[index].PlayerID = playerID
|
||||
second.PlayerDiscordIDs[playerID] = "other-user-" + string(rune('1'+index))
|
||||
}
|
||||
second.Roster.Teams[0].CaptainPlayerID = second.Roster.Teams[0].Slots[0].PlayerID
|
||||
store.rosters["event-1"], store.rosters["event-2"] = first, second
|
||||
store.active = []application.DiscordRoleRoster{first, second}
|
||||
store.jobs = []application.DiscordRoleSyncJob{
|
||||
{ID: 1, EventID: "event-1", Action: application.DiscordRoleActionReconcile},
|
||||
{ID: 2, EventID: "event-2", Action: application.DiscordRoleActionReconcile},
|
||||
}
|
||||
worker, err := NewRoleWorker(store, RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store.active = []application.DiscordRoleRoster{second}
|
||||
store.jobs = []application.DiscordRoleSyncJob{{ID: 3, EventID: "event-1", Action: application.DiscordRoleActionTeardown}}
|
||||
if _, err = worker.ProcessNext(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
globalAssignments := 0
|
||||
for _, role := range store.roles {
|
||||
if role.Scope == application.DiscordRoleScopeEvent && role.EventID == "event-1" {
|
||||
t.Fatalf("event-1 role was not deleted: %+v", role)
|
||||
}
|
||||
if role.Scope == application.DiscordRoleScopeGlobal {
|
||||
globalAssignments += len(store.assignments[role.DiscordRoleID])
|
||||
for userID := range store.assignments[role.DiscordRoleID] {
|
||||
if !strings.HasPrefix(userID, "other-user-") {
|
||||
t.Fatalf("stale global assignment remained after teardown: %s", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if globalAssignments != 5 {
|
||||
t.Fatalf("expected five global assignments for the other event, got %d", globalAssignments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleManagerReturnsRateLimit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.Header().Set("Retry-After", "2")
|
||||
http.Error(response, `{"message":"rate limited"}`, http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = manager.AddMemberRole(context.Background(), "user", "role")
|
||||
var httpErr *discordHTTPError
|
||||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusTooManyRequests || retryDelay(1, err) != 2*time.Second {
|
||||
t.Fatalf("unexpected rate limit error: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleManagerReturnsServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(response, `{"message":"temporary failure"}`, http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
manager, err := newRoleManager(RoleSyncConfig{BotToken: "token", GuildID: "guild", APIBaseURL: server.URL, HTTPClient: server.Client()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = manager.DeleteRole(context.Background(), "role")
|
||||
var httpErr *discordHTTPError
|
||||
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("unexpected server error: %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testDiscordRoster(name string) application.DiscordRoleRoster {
|
||||
slots := []domain.Slot{
|
||||
{PlayerID: "player-1", Role: domain.Tank},
|
||||
{PlayerID: "player-2", Role: domain.Damage},
|
||||
{PlayerID: "player-3", Role: domain.Damage},
|
||||
{PlayerID: "player-4", Role: domain.Support},
|
||||
{PlayerID: "player-5", Role: domain.Support},
|
||||
}
|
||||
discordIDs := make(map[string]string)
|
||||
for index := 1; index <= 5; index++ {
|
||||
discordIDs["player-"+string(rune('0'+index))] = "user-" + string(rune('0'+index))
|
||||
}
|
||||
return application.DiscordRoleRoster{
|
||||
Roster: domain.RosterDraft{
|
||||
EventID: "event-1",
|
||||
Teams: []domain.Team{{
|
||||
ID: "team-1", EventID: "event-1", Name: name, CaptainPlayerID: "player-1", Slots: slots,
|
||||
}},
|
||||
Confirmed: true,
|
||||
},
|
||||
PlayerDiscordIDs: discordIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func assignmentCount(assignments map[string]map[string]bool) int {
|
||||
total := 0
|
||||
for _, users := range assignments {
|
||||
total += len(users)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func managedRoleID(roles map[string]application.DiscordManagedRole, kind string) string {
|
||||
for _, role := range roles {
|
||||
if role.Scope == application.DiscordRoleScopeGlobal && role.Kind == kind {
|
||||
return role.DiscordRoleID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -292,12 +292,31 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
type createEventRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
RegistrationDeadline time.Time `json:"registrationDeadline"`
|
||||
PingEveryone *bool `json:"pingEveryone"`
|
||||
}
|
||||
|
||||
func (in createEventRequest) options() application.CreateEventOptions {
|
||||
return application.CreateEventOptions{PingEveryone: in.PingEveryone == nil || *in.PingEveryone}
|
||||
}
|
||||
|
||||
func (s *Server) createEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var in domain.Event
|
||||
var in createEventRequest
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CreateEvent(r.Context(), who(r).account, in)
|
||||
out, err := s.service.CreateEvent(r.Context(), who(r).account, domain.Event{
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
StartsAt: in.StartsAt,
|
||||
EndsAt: in.EndsAt,
|
||||
RegistrationDeadline: in.RegistrationDeadline,
|
||||
}, in.options())
|
||||
respond(w, out, err, http.StatusCreated)
|
||||
}
|
||||
|
||||
|
||||
16
backend/internal/adapter/httpapi/server_test.go
Normal file
16
backend/internal/adapter/httpapi/server_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateEventRequestDefaultsEveryonePing(t *testing.T) {
|
||||
if !((createEventRequest{}).options().PingEveryone) {
|
||||
t.Fatal("legacy create requests without pingEveryone must keep @everyone enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventRequestCanDisableEveryonePing(t *testing.T) {
|
||||
disabled := false
|
||||
if (createEventRequest{PingEveryone: &disabled}).options().PingEveryone {
|
||||
t.Fatal("explicit false pingEveryone must disable the ping")
|
||||
}
|
||||
}
|
||||
215
backend/internal/adapter/postgres/discord_roles.go
Normal file
215
backend/internal/adapter/postgres/discord_roles.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) EnqueueDiscordRoleSync(ctx context.Context, eventID, action string, generation int64) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation,role_snapshot)
|
||||
SELECT $1,$2,$3,COALESCE(jsonb_agg(discord_role_id) FILTER (WHERE discord_role_id IS NOT NULL),'[]'::jsonb)
|
||||
FROM discord_managed_roles WHERE scope='event' AND event_id=$1
|
||||
ON CONFLICT(event_id,action,generation) DO UPDATE
|
||||
SET status='pending',available_at=now(),updated_at=now(),completed_at=NULL`,
|
||||
eventID, action, generation)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SeedDiscordRoleSyncJobs(ctx context.Context) error {
|
||||
_, 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`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimDiscordRoleSyncJob(ctx context.Context) (application.DiscordRoleSyncJob, error) {
|
||||
var job application.DiscordRoleSyncJob
|
||||
var snapshot []byte
|
||||
err := s.pool.QueryRow(ctx, `WITH candidate AS (
|
||||
SELECT id FROM discord_role_sync_jobs
|
||||
WHERE (status='pending' AND available_at<=now())
|
||||
OR (status='processing' AND updated_at<now()-interval '5 minutes')
|
||||
ORDER BY id
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE discord_role_sync_jobs j
|
||||
SET status='processing',attempts=j.attempts+1,updated_at=now()
|
||||
FROM candidate
|
||||
WHERE j.id=candidate.id
|
||||
RETURNING j.id,j.event_id,j.action,j.generation,j.role_snapshot,j.attempts`).
|
||||
Scan(&job.ID, &job.EventID, &job.Action, &job.Generation, &snapshot, &job.Attempts)
|
||||
if err != nil {
|
||||
return job, mapError(err)
|
||||
}
|
||||
if err = json.Unmarshal(snapshot, &job.RoleSnapshot); err != nil {
|
||||
return job, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) CompleteDiscordRoleSyncJob(ctx context.Context, jobID int64, warning string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE discord_role_sync_jobs
|
||||
SET status='completed',last_error=$2,completed_at=now(),updated_at=now()
|
||||
WHERE id=$1`, jobID, warning)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RetryDiscordRoleSyncJob(ctx context.Context, jobID int64, message string, availableAt time.Time) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE discord_role_sync_jobs
|
||||
SET status='pending',last_error=$2,available_at=$3,updated_at=now()
|
||||
WHERE id=$1`, jobID, message, availableAt)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
FROM discord_managed_roles
|
||||
WHERE scope='global' OR event_id=$1
|
||||
ORDER BY scope,event_id,team_id,kind`, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, role)
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDiscordManagedRole(ctx context.Context, role application.DiscordManagedRole) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM discord_managed_roles
|
||||
WHERE scope=$1 AND event_id=$2 AND team_id=$3 AND kind=$4`,
|
||||
role.Scope, role.EventID, role.TeamID, role.Kind)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListDiscordRoleAssignments(ctx context.Context, discordRoleID string) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT discord_user_id FROM discord_role_assignments
|
||||
WHERE discord_role_id=$1 ORDER BY discord_user_id`, discordRoleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
userIDs := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err = rows.Scan(&userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
return userIDs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertDiscordRoleAssignment(ctx context.Context, discordRoleID, discordUserID string) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO discord_role_assignments(discord_role_id,discord_user_id)
|
||||
VALUES($1,$2) ON CONFLICT(discord_role_id,discord_user_id) DO UPDATE SET updated_at=now()`,
|
||||
discordRoleID, discordUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDiscordRoleAssignment(ctx context.Context, discordRoleID, discordUserID string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM discord_role_assignments
|
||||
WHERE discord_role_id=$1 AND discord_user_id=$2`, discordRoleID, discordUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetDiscordRoleRoster(ctx context.Context, eventID string) (application.DiscordRoleRoster, error) {
|
||||
roster, err := s.GetRoster(ctx, eventID)
|
||||
if err != nil {
|
||||
return application.DiscordRoleRoster{}, err
|
||||
}
|
||||
return s.discordRoleRoster(ctx, roster)
|
||||
}
|
||||
|
||||
func (s *Store) ListActiveDiscordRoleRosters(ctx context.Context) ([]application.DiscordRoleRoster, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT er.body
|
||||
FROM event_rosters er
|
||||
JOIN events e ON e.id=er.event_id
|
||||
WHERE e.state IN ('RostersConfirmed','BracketDraft','Live')
|
||||
AND COALESCE((er.body->>'confirmed')::boolean,false)=true
|
||||
ORDER BY er.event_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
rosters := make([]domain.RosterDraft, 0)
|
||||
for rows.Next() {
|
||||
var body []byte
|
||||
var roster domain.RosterDraft
|
||||
if err = rows.Scan(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(body, &roster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rosters = append(rosters, roster)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]application.DiscordRoleRoster, 0, len(rosters))
|
||||
for _, roster := range rosters {
|
||||
item, rosterErr := s.discordRoleRoster(ctx, roster)
|
||||
if rosterErr != nil {
|
||||
return nil, rosterErr
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) discordRoleRoster(ctx context.Context, roster domain.RosterDraft) (application.DiscordRoleRoster, error) {
|
||||
playerIDs := make([]string, 0)
|
||||
for _, team := range roster.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
if slot.PlayerID != "" {
|
||||
playerIDs = append(playerIDs, slot.PlayerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
discordIDs := make(map[string]string)
|
||||
if len(playerIDs) > 0 {
|
||||
rows, err := s.pool.Query(ctx, `SELECT p.id,a.discord_id
|
||||
FROM players p JOIN accounts a ON a.id=p.account_id
|
||||
WHERE p.id=ANY($1)`, playerIDs)
|
||||
if err != nil {
|
||||
return application.DiscordRoleRoster{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var playerID, discordID string
|
||||
if err = rows.Scan(&playerID, &discordID); err != nil {
|
||||
return application.DiscordRoleRoster{}, err
|
||||
}
|
||||
discordIDs[playerID] = discordID
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return application.DiscordRoleRoster{}, err
|
||||
}
|
||||
}
|
||||
return application.DiscordRoleRoster{Roster: roster, PlayerDiscordIDs: discordIDs}, nil
|
||||
}
|
||||
@@ -312,6 +312,12 @@ func (s *Store) DeleteEvent(ctx context.Context, eventID string) error {
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM series WHERE body->>'eventId'=$1`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO discord_role_sync_jobs(event_id,action,generation,role_snapshot)
|
||||
SELECT $1,'teardown',$2,COALESCE(jsonb_agg(discord_role_id) FILTER (WHERE discord_role_id IS NOT NULL),'[]'::jsonb)
|
||||
FROM discord_managed_roles WHERE scope='event' AND event_id=$1`,
|
||||
eventID, time.Now().UTC().UnixNano()); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,6 +54,77 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
||||
if !bracketTable {
|
||||
t.Fatal("event_bracket_drafts table is missing")
|
||||
}
|
||||
for _, table := range []string{"discord_managed_roles", "discord_role_assignments", "discord_role_sync_jobs"} {
|
||||
var exists bool
|
||||
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, table).Scan(&exists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatalf("%s table is missing", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordRoleSyncPersistence(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
eventID := fmt.Sprintf("discord-sync-%d", time.Now().UnixNano())
|
||||
role := application.DiscordManagedRole{
|
||||
Scope: application.DiscordRoleScopeEvent, EventID: eventID, TeamID: "team-1",
|
||||
Kind: application.DiscordRoleKindTeam, DiscordRoleID: eventID + "-role", RoleName: "Alpha",
|
||||
}
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_managed_roles WHERE event_id=$1`, eventID)
|
||||
}()
|
||||
if err = store.UpsertDiscordManagedRole(ctx, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.UpsertDiscordRoleAssignment(ctx, role.DiscordRoleID, "discord-user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assignments, err := store.ListDiscordRoleAssignments(ctx, role.DiscordRoleID)
|
||||
if err != nil || len(assignments) != 1 || assignments[0] != "discord-user" {
|
||||
t.Fatalf("unexpected assignments: %v err=%v", assignments, err)
|
||||
}
|
||||
if _, err = store.pool.Exec(ctx, `UPDATE discord_role_sync_jobs SET status='completed' WHERE status='pending'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.EnqueueDiscordRoleSync(ctx, eventID, application.DiscordRoleActionReconcile, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job, err := store.ClaimDiscordRoleSyncJob(ctx)
|
||||
if err != nil || job.EventID != eventID || len(job.RoleSnapshot) != 1 || job.Attempts != 1 {
|
||||
t.Fatalf("unexpected claimed job: %+v err=%v", job, err)
|
||||
}
|
||||
if err = store.RetryDiscordRoleSyncJob(ctx, job.ID, "retry", time.Now().Add(-time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job, err = store.ClaimDiscordRoleSyncJob(ctx)
|
||||
if err != nil || job.Attempts != 2 {
|
||||
t.Fatalf("unexpected retried job: %+v err=%v", job, err)
|
||||
}
|
||||
if err = store.CompleteDiscordRoleSyncJob(ctx, job.ID, "warning"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status, warning string
|
||||
if err = store.pool.QueryRow(ctx, `SELECT status,last_error FROM discord_role_sync_jobs WHERE id=$1`, job.ID).Scan(&status, &warning); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "completed" || warning != "warning" {
|
||||
t.Fatalf("unexpected completed job state: status=%s warning=%s", status, warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullScrimPipeline(t *testing.T) {
|
||||
@@ -82,6 +153,7 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM audit_log WHERE actor_account_id=$1`, accountID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
||||
for _, seriesID := range seriesIDs {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE id=$1`, seriesID)
|
||||
}
|
||||
@@ -97,7 +169,7 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
||||
ID: eventID, Name: "Pipeline", StartsAt: now.Add(24 * time.Hour), EndsAt: now.Add(28 * time.Hour),
|
||||
RegistrationDeadline: now.Add(23 * time.Hour), RulesetID: "standard-control-hybrid-control",
|
||||
})
|
||||
}, application.CreateEventOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -217,13 +289,14 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE tournament_id=$1`, tournamentID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM discord_role_sync_jobs WHERE event_id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
service := application.New(store, discardPublisher{}, nil)
|
||||
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
|
||||
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
|
||||
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
|
||||
})
|
||||
}, application.CreateEventOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user