Add Discord event announcement functionality to backend
This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
|
||||
import { useLanguage } from './i18n-context'
|
||||
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
||||
import { activeHeroBanMap } from './series-utils'
|
||||
import { BracketEditor } from './components/bracket-editor/BracketEditor'
|
||||
|
||||
type RouterContext = { session: Session | null }
|
||||
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
||||
@@ -86,6 +87,8 @@ function AppShell() {
|
||||
void client.invalidateQueries({ queryKey: ['series', id] })
|
||||
} else if (scope === 'tournament') {
|
||||
void client.invalidateQueries({ queryKey: ['tournament'] })
|
||||
} else if (scope === 'bracket' && id) {
|
||||
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
|
||||
} else if (scope === 'team') {
|
||||
void client.invalidateQueries({ queryKey: ['teams'] })
|
||||
void client.invalidateQueries({ queryKey: ['series'] })
|
||||
@@ -475,9 +478,20 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
const roster = useQuery({
|
||||
queryKey: ['roster', eventId],
|
||||
queryFn: () => api.roster(eventId),
|
||||
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
|
||||
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(event.data.workflowState)),
|
||||
retry: false,
|
||||
})
|
||||
const bracketDraft = useQuery({
|
||||
queryKey: ['bracket-draft', eventId],
|
||||
queryFn: () => api.bracketDraft(eventId),
|
||||
enabled: event.data?.workflowState === 'BracketDraft',
|
||||
retry: false,
|
||||
})
|
||||
const bracketTeams = useQuery({
|
||||
queryKey: ['teams', eventId],
|
||||
queryFn: () => api.teams(eventId),
|
||||
enabled: event.data?.workflowState === 'BracketDraft',
|
||||
})
|
||||
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
|
||||
const [selectedCandidate, setSelectedCandidate] = useState(0)
|
||||
const refresh = useCallback(() => {
|
||||
@@ -510,6 +524,29 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
onSuccess: refresh,
|
||||
onError: refresh,
|
||||
})
|
||||
const initializeBracket = useMutation({
|
||||
mutationFn: () => api.initializeBracket(eventId, event.data!.version),
|
||||
onSuccess: (draft) => {
|
||||
client.setQueryData(['bracket-draft', eventId], draft)
|
||||
refresh()
|
||||
},
|
||||
onError: refresh,
|
||||
})
|
||||
const saveBracket = useMutation({
|
||||
mutationFn: (matches: Parameters<typeof api.updateBracket>[1]) => api.updateBracket(eventId, matches, bracketDraft.data!.version),
|
||||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||||
onError: () => void bracketDraft.refetch(),
|
||||
})
|
||||
const resetBracket = useMutation({
|
||||
mutationFn: () => api.resetBracket(eventId, bracketDraft.data!.version),
|
||||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||||
onError: () => void bracketDraft.refetch(),
|
||||
})
|
||||
const confirmBracket = useMutation({
|
||||
mutationFn: () => api.confirmBracket(eventId, bracketDraft.data!.version),
|
||||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||||
onError: () => void bracketDraft.refetch(),
|
||||
})
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
||||
onSuccess: (result) => {
|
||||
@@ -527,6 +564,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
onSuccess: (updated) => {
|
||||
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
|
||||
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
|
||||
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
|
||||
refresh()
|
||||
},
|
||||
onError: refresh,
|
||||
@@ -534,11 +572,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
|
||||
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
|
||||
const state = event.data.workflowState
|
||||
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error ?? back.error
|
||||
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? initializeBracket.error ?? saveBracket.error ?? resetBracket.error ?? confirmBracket.error ?? start.error ?? back.error ?? bracketDraft.error ?? bracketTeams.error
|
||||
return <div className="workflow-control">
|
||||
<section className="card workflow-progress">
|
||||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
|
||||
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
|
||||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
|
||||
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].indexOf(state)
|
||||
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
|
||||
})}
|
||||
</section>
|
||||
@@ -547,9 +585,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||||
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||||
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} allowCaptainAssignment={false} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
|
||||
{roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
||||
{roster.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
||||
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
||||
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
|
||||
{state === 'RostersConfirmed' && <WorkflowAction title="Configure bracket" description="Build the match order and connect winner or loser feeds before starting." button="Open bracket editor" pending={initializeBracket.isPending} onClick={() => initializeBracket.mutate()} />}
|
||||
{state === 'BracketDraft' && (bracketDraft.isLoading || bracketTeams.isLoading) && <LoadingState label="Loading bracket editor…" />}
|
||||
{state === 'BracketDraft' && bracketDraft.data && bracketTeams.data && <BracketEditor draft={bracketDraft.data} teams={bracketTeams.data} pending={saveBracket.isPending || resetBracket.isPending || confirmBracket.isPending || start.isPending} error={error?.message} onSave={(matches) => saveBracket.mutate(matches)} onReset={() => resetBracket.mutate()} onConfirm={() => confirmBracket.mutate()} onStart={() => start.mutate()} />}
|
||||
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
|
||||
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
|
||||
{error && <p className="error-note">{error.message}</p>}
|
||||
|
||||
Reference in New Issue
Block a user