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:
@@ -80,6 +80,21 @@ describe('backend DTO adapters', () => {
|
||||
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
|
||||
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
|
||||
expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
|
||||
const graph = adaptTournament({
|
||||
id: 'graph-1',
|
||||
eventId: 'event-1',
|
||||
name: 'Custom Cup',
|
||||
winnerTeamId: '',
|
||||
teamIds: ['a', 'b'],
|
||||
version: 0,
|
||||
rounds: [],
|
||||
matches: [
|
||||
{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'a' }, slotB: { kind: 'Team', teamId: 'b' }, teamAId: 'a', teamBId: 'b', series },
|
||||
{ id: 'm2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'm1' }, slotB: { kind: 'Winner', matchId: 'm1' } },
|
||||
],
|
||||
})
|
||||
expect(graph.matches[0].status).toBe('live')
|
||||
expect(graph.matches[1]).toMatchObject({ teamAlpha: 'Loser of R1M1', teamBeta: 'Winner of R1M1' })
|
||||
const heroDraft = adaptSeries({
|
||||
...series,
|
||||
phase: 'HeroBan',
|
||||
@@ -149,4 +164,23 @@ describe('actual backend routes', () => {
|
||||
body: JSON.stringify({ name: 'Ember Wolves' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('updates the versioned bracket graph', async () => {
|
||||
const draft = {
|
||||
eventId: 'event-1',
|
||||
teamIds: ['a', 'b'],
|
||||
matches: [{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team' as const, teamId: 'a' }, slotB: { kind: 'Team' as const, teamId: 'b' } }],
|
||||
version: 2,
|
||||
confirmed: false,
|
||||
}
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(draft), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
await api.updateBracket(draft.eventId, draft.matches, 1)
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/events/event-1/bracket-draft', expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ matches: draft.matches, expectedVersion: 1 }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface MixEvent {
|
||||
activeSeriesId?: string
|
||||
tournamentId?: string
|
||||
}
|
||||
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
|
||||
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'BracketDraft' | 'Live' | 'Completed' | 'Cancelled'
|
||||
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
|
||||
export interface Team {
|
||||
id: string
|
||||
@@ -122,6 +122,27 @@ export interface EventInput {
|
||||
endsAt: string
|
||||
registrationDeadline: string
|
||||
}
|
||||
export type BracketSlotSource =
|
||||
| { kind: 'Team'; teamId: string; matchId?: never }
|
||||
| { kind: 'Winner' | 'Loser'; matchId: string; teamId?: never }
|
||||
export interface BracketDraftMatch {
|
||||
id: string
|
||||
round: number
|
||||
order: number
|
||||
slotA: BracketSlotSource
|
||||
slotB: BracketSlotSource
|
||||
seriesId?: string
|
||||
teamAId?: string
|
||||
teamBId?: string
|
||||
winnerTeamId?: string
|
||||
}
|
||||
export interface BracketDraft {
|
||||
eventId: string
|
||||
teamIds: string[]
|
||||
matches: BracketDraftMatch[]
|
||||
version: number
|
||||
confirmed: boolean
|
||||
}
|
||||
|
||||
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
|
||||
export interface RawAccount {
|
||||
@@ -174,7 +195,7 @@ export interface RawSeries {
|
||||
export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
|
||||
export interface RawTournament {
|
||||
id: string; eventId: string; name: string; winnerTeamId: string
|
||||
teamIds: string[]; rounds: RawSeries[][]
|
||||
teamIds: string[]; rounds: RawSeries[][]; matches?: Array<BracketDraftMatch & { series?: RawSeries }>; version?: number
|
||||
}
|
||||
export interface RawRealtimeEvent { topic: string; data: unknown }
|
||||
|
||||
@@ -391,6 +412,37 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
|
||||
}
|
||||
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
|
||||
const names = new Map(teams.map((team) => [team.id, team.name]))
|
||||
if (raw.matches?.length) {
|
||||
const maxRound = Math.max(...raw.matches.map((match) => match.round))
|
||||
const sourceName = (source: BracketSlotSource) => {
|
||||
if (source.kind === 'Team') return names.get(source.teamId) ?? source.teamId
|
||||
const upstream = raw.matches?.find((match) => match.id === source.matchId)
|
||||
return `${source.kind} of ${upstream ? `R${upstream.round + 1}M${upstream.order + 1}` : 'match'}`
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.name,
|
||||
rounds: Array.from({ length: maxRound + 1 }, (_, index) => index === maxRound ? 'Grand Final' : index === maxRound - 1 ? 'Semifinals' : `Round ${index + 1}`),
|
||||
matches: raw.matches.map((match) => {
|
||||
const adapted = match.series ? adaptSeries(match.series, teams) : undefined
|
||||
return {
|
||||
id: match.id,
|
||||
round: match.round,
|
||||
slot: match.order,
|
||||
label: `Match ${match.order + 1}`,
|
||||
teamAlpha: names.get(match.teamAId ?? '') ?? match.teamAId ?? sourceName(match.slotA),
|
||||
teamBeta: names.get(match.teamBId ?? '') ?? match.teamBId ?? sourceName(match.slotB),
|
||||
teamAlphaId: match.teamAId,
|
||||
teamBetaId: match.teamBId,
|
||||
scoreAlpha: adapted?.score.alpha,
|
||||
scoreBeta: adapted?.score.beta,
|
||||
winner: match.winnerTeamId ? (match.winnerTeamId === match.teamAId ? 'alpha' : 'beta') : undefined,
|
||||
seriesId: match.seriesId ?? match.series?.id,
|
||||
status: match.winnerTeamId ? 'completed' : match.series?.phase ? 'live' : match.teamAId && match.teamBId ? 'ready' : 'pending',
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
title: raw.name,
|
||||
@@ -599,6 +651,15 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
|
||||
}),
|
||||
bracketDraft: (eventId: string) => request<BracketDraft>(`/events/${eventId}/bracket-draft`),
|
||||
initializeBracket: (eventId: string, expectedVersion: number) =>
|
||||
request<BracketDraft>(`/events/${eventId}/bracket-draft/initialize`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
|
||||
updateBracket: (eventId: string, matches: BracketDraftMatch[], expectedVersion: number) =>
|
||||
request<BracketDraft>(`/events/${eventId}/bracket-draft`, { method: 'PUT', body: JSON.stringify({ matches, expectedVersion }) }),
|
||||
resetBracket: (eventId: string, expectedVersion: number) =>
|
||||
request<BracketDraft>(`/events/${eventId}/bracket-draft/reset`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
|
||||
confirmBracket: (eventId: string, expectedVersion: number) =>
|
||||
request<BracketDraft>(`/events/${eventId}/bracket-draft/confirm`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
|
||||
startScrim: async (eventId: string, expectedVersion: number) => {
|
||||
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BalanceCandidate,
|
||||
Bracket,
|
||||
BracketDraft,
|
||||
MixEvent,
|
||||
Player,
|
||||
Registration,
|
||||
@@ -294,3 +295,15 @@ export const demoBracket: Bracket = {
|
||||
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
|
||||
],
|
||||
}
|
||||
|
||||
export const demoBracketDraft: BracketDraft = {
|
||||
eventId: 'event-3',
|
||||
teamIds: ['team-alpha', 'team-beta', 'team-neon'],
|
||||
version: 2,
|
||||
confirmed: false,
|
||||
matches: [
|
||||
{ id: 'draft-m1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'team-alpha' }, slotB: { kind: 'Team', teamId: 'team-beta' } },
|
||||
{ id: 'draft-m2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'draft-m1' }, slotB: { kind: 'Team', teamId: 'team-neon' } },
|
||||
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user