Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:10:02 +03:00
parent 59c0bdb345
commit 4b889fb8a0
30 changed files with 2766 additions and 149 deletions

View File

@@ -16,6 +16,7 @@ export interface Player {
ratings: Ratings
preferredRoles: PlayerRole[]
preferredPlayerIds: string[]
avoidedPlayerIds: string[]
}
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
@@ -35,11 +36,17 @@ export interface MixEvent {
startsAt: string
endsAt: string
registrationDeadline: string
status: 'registration' | 'balancing' | 'live' | 'completed'
status: 'registration' | 'balancing' | 'live' | 'completed' | 'cancelled'
rulesetName: string
counts: Record<RsvpStatus, number>
myRsvp?: RsvpStatus
workflowState: EventWorkflowState
version: number
rulesetId: string
activeSeriesId?: string
tournamentId?: string
}
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
@@ -86,7 +93,7 @@ export interface Series {
title: string
instruction: string
activeTeam?: TeamSide
kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
kind: 'coin_toss' | 'map_ban' | 'map_pick' | 'hero_ban' | 'result' | 'complete'
version: number
}
options: DraftOption[]
@@ -124,12 +131,14 @@ export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
state: EventWorkflowState; version: number; rulesetId: string; activeSeriesId: string; tournamentId: string
}
export interface RawRSVP {
eventId: string; playerId: string; actorAccountId: string
@@ -140,14 +149,26 @@ export interface RawTeam {
id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[]
}
export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] }
export interface RawRosterDraft {
eventId: string; teams: RawTeam[]; reserve: string[]; version: number; confirmed: boolean
}
export interface RawMapResult {
mapName: string; actorAccountId: string; outcome: MapOutcome
recordedAt: string; correctionOf?: number
}
export interface RawSeries {
id: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
id: string; eventId: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; rulesetId: string
phase: 'CoinTossPending' | 'MapBan' | 'MapPick' | 'HeroBan' | 'Playing' | 'Completed'
coinToss?: { seed: string; winnerTeamId: string; performedAt: string }
mapDraft?: { pool: string[]; banned: string[]; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] }
heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }>; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] }
currentMap: string; playedMaps: string[]; nextMapPickerId: string; availableMaps: string[]
maps: Array<{ number: number; name: string; heroBans: RawDraftAction[]; result?: RawMapResult }>
audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }>
results: RawMapResult[]; version: number
}
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[][]
@@ -178,6 +199,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
avoidedPlayerIds: raw.avoidedPlayerIds ?? [],
}
}
export function adaptSession(raw: RawSession): Session {
@@ -194,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
const starts = new Date(raw.startsAt).getTime()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
@@ -207,10 +228,18 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
startsAt: raw.startsAt,
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
status: raw.state === 'Cancelled' ? 'cancelled'
: raw.state === 'Completed' || now > ends ? 'completed'
: raw.state === 'Live' ? 'live'
: raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing',
rulesetName: 'Server ruleset',
counts,
myRsvp: own ? statusFromRaw(own.status) : undefined,
workflowState: raw.state,
version: raw.version,
rulesetId: raw.rulesetId,
activeSeriesId: raw.activeSeriesId || undefined,
tournamentId: raw.tournamentId || undefined,
}
}
export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, session?: Session): Registration {
@@ -220,6 +249,7 @@ export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, se
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
}
return {
id: `${raw.eventId}:${raw.playerId}`,
@@ -246,6 +276,7 @@ export function adaptTeam(raw: RawTeam, players: Map<string, Player>, index = 0)
},
preferredRoles: [roleFromRaw(slot.role)],
preferredPlayerIds: [],
avoidedPlayerIds: [],
},
assignedRole: roleFromRaw(slot.role),
}))
@@ -294,38 +325,54 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0),
beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0),
}), { alpha: 0, beta: 0 })
const complete = Boolean(raw.winnerTeamId)
const complete = raw.phase === 'Completed' || Boolean(raw.winnerTeamId)
const phase = raw.phase ?? (complete ? 'Completed' : 'Playing')
const status: Series['status'] = phase === 'CoinTossPending' ? 'coin_toss'
: phase === 'MapBan' || phase === 'MapPick' ? 'map_draft'
: phase === 'HeroBan' ? 'hero_draft'
: phase === 'Completed' ? 'completed' : 'playing'
const nextMapTeam = raw.mapDraft
? (raw.mapDraft.actions.length % 2 === 0 ? raw.mapDraft.firstTeamId : raw.mapDraft.teamIds.find((id) => id !== raw.mapDraft?.firstTeamId))
: undefined
const nextHeroTeam = raw.heroDraft
? (raw.heroDraft.currentBans.length % 2 === 0 ? raw.heroDraft.firstTeamId : raw.heroDraft.teamIds.find((id) => id !== raw.heroDraft?.firstTeamId))
: undefined
const activeTeamId = phase === 'MapBan' ? nextMapTeam : phase === 'MapPick' ? raw.nextMapPickerId : phase === 'HeroBan' ? nextHeroTeam : undefined
const kind: Series['currentStep']['kind'] = phase === 'CoinTossPending' ? 'coin_toss'
: phase === 'MapBan' ? 'map_ban' : phase === 'MapPick' ? 'map_pick' : phase === 'HeroBan' ? 'hero_ban' : phase === 'Completed' ? 'complete' : 'result'
const options: DraftOption[] = phase === 'MapBan' && raw.mapDraft
? raw.mapDraft.pool.filter((name) => !raw.mapDraft?.banned.includes(name)).map((name) => ({ id: name, name }))
: phase === 'MapPick'
? (raw.availableMaps ?? []).map((name) => ({ id: name, name }))
: phase === 'HeroBan' && raw.heroDraft
? raw.heroDraft.heroes.filter((hero) => !raw.heroDraft?.currentBans.some((ban) => ban.value === hero.name)).map((hero) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
: []
return {
id: raw.id,
tournamentId: raw.tournamentId,
status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
status,
roundLabel: 'Tournament series',
teamAlpha: alpha,
teamBeta: beta,
score,
version: raw.version,
currentStep: {
title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`,
instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.',
kind: complete ? 'complete' : 'result',
title: phase === 'CoinTossPending' ? 'Coin toss' : phase === 'MapBan' ? 'Map bans' : phase === 'MapPick' ? 'Loser picks the next map' : phase === 'HeroBan' ? 'Hero bans' : complete ? 'Series complete' : `Record ${raw.currentMap || `map ${activeResults.length + 1}`}`,
instruction: complete ? 'The series is complete.' : phase === 'Playing' ? 'Record the authoritative map result.' : 'The active captain or an admin can act.',
activeTeam: activeTeamId ? (activeTeamId === raw.teamAId ? 'alpha' : 'beta') : undefined,
kind,
version: raw.version,
},
options: [],
maps: activeResults.map((result, index) => ({
number: index + 1,
name: result.mapName,
options,
maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({
number: map.number,
name: map.name,
mode: 'Map',
status: 'completed',
winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw',
heroBans: [],
})),
audit: raw.results.map((result, index) => ({
id: `result-${index}`,
kind: 'result',
summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`,
actorName: result.actorAccountId,
createdAt: result.recordedAt,
status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting',
winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined,
heroBans: map.heroBans.map((ban) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
})),
audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })),
}
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
@@ -399,9 +446,8 @@ const raw = {
}
async function adaptSeriesWithTeams(series: RawSeries) {
if (!series.tournamentId) return adaptSeries(series)
const tournament = await request<RawTournament>(`/tournaments/${series.tournamentId}`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const eventId = series.eventId || (series.tournamentId ? (await request<RawTournament>(`/tournaments/${series.tournamentId}`)).eventId : '')
const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index)))
}
@@ -415,6 +461,7 @@ export const api = {
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
avoidedPlayerIds: string[],
) =>
adaptPlayer(await request<RawPlayer>('/me/player', {
method: 'PATCH',
@@ -423,6 +470,7 @@ export const api = {
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
avoidedPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
@@ -467,8 +515,13 @@ export const api = {
])
return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session))
},
removeParticipant: (eventId: string, playerId: string) =>
request<void>(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
cancelEvent: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),
deleteEvent: (eventId: string) => request<void>(`/events/${eventId}`, { method: 'DELETE' }),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request<RawBalanceCandidate[]>(`/events/${eventId}/balance`, { method: 'POST' }),
@@ -477,6 +530,50 @@ export const api = {
const adaptedPlayers = players.map(adaptPlayer)
return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index))
},
closeRegistration: async (eventId: string, rulesetId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/registration/close`, {
method: 'POST',
body: JSON.stringify({ rulesetId, expectedVersion }),
})),
generateWorkflowBalance: async (eventId: string, expectedVersion: number) => {
const [result, players] = await Promise.all([
request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
raw.players(),
])
const adaptedPlayers = players.map(adaptPlayer)
return {
event: adaptEvent(result.event),
candidates: result.candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index)),
}
},
roster: (eventId: string) => request<RawRosterDraft>(`/events/${eventId}/roster`),
selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster`, {
method: 'PUT',
body: JSON.stringify({ candidate: candidate.payload, expectedVersion }),
}),
swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }),
substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) =>
request<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster/captain`, {
method: 'PUT',
body: JSON.stringify({ teamId, playerId, expectedVersion }),
}),
confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) =>
request<RawEvent>(`/events/${eventId}/roster/confirm`, {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
startScrim: (eventId: string, expectedVersion: number) =>
request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',
@@ -512,6 +609,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
tossSeriesCoin: async (seriesId: string, seed: string, actingTeamId: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/coin-toss`, {
method: 'POST',
body: JSON.stringify({ seed, actingTeamId, expectedVersion }),
})),
banSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-bans`, {
method: 'POST',
body: JSON.stringify({ teamId, map, expectedVersion }),
})),
pickSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-pick`, {
method: 'POST',
body: JSON.stringify({ teamId, map, expectedVersion }),
})),
banSeriesHero: async (seriesId: string, teamId: string, hero: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/hero-bans`, {
method: 'POST',
body: JSON.stringify({ teamId, hero, expectedVersion }),
})),
recordSeriesMap: async (seriesId: string, outcome: MapOutcome, actingTeamId: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/map-result`, {
method: 'POST',
body: JSON.stringify({ outcome, actingTeamId, expectedVersion }),
})),
correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results/${index}`, {
method: 'PATCH',
@@ -536,6 +658,7 @@ export function subscribeToEvents(
onMessage: (event: RawRealtimeEvent) => void,
onStatus?: (status: 'connected' | 'reconnecting') => void,
) {
if (typeof EventSource === 'undefined') return () => undefined
const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true })
source.onopen = () => onStatus?.('connected')
source.onerror = () => onStatus?.('reconnecting')