Add RSVP role synchronization and localization support for Discord
This commit introduces functionality for managing event-specific RSVP roles in Discord, including roles for "Registered", "Going", "Maybe", and "Not Going". The `RoleWorker` has been enhanced to handle these roles based on user registrations, with localization support for role names in Russian and English. Additionally, the database schema has been updated to accommodate new role types and actions, and the service layer has been modified to trigger role synchronization upon relevant event updates. Tests have been added to ensure the correct behavior of the new RSVP handling logic.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
29
backend/migrations/012_discord_rsvp_roles.sql
Normal file
29
backend/migrations/012_discord_rsvp_roles.sql
Normal file
@@ -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')
|
||||
);
|
||||
Reference in New Issue
Block a user