diff --git a/api/teams/[id].ts b/api/teams/[id].ts index dac1b41..837e202 100644 --- a/api/teams/[id].ts +++ b/api/teams/[id].ts @@ -126,6 +126,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (update.playerIds !== undefined && tournamentStartedPatch) { return corsRes.status(400).json({ error: 'Team roster cannot be changed after the tournament has started' }); } + if (update.groupIndex !== undefined && tournamentStartedPatch) { + return corsRes.status(400).json({ error: 'Team group cannot be changed after the tournament has started' }); + } if (update.groupIndex !== undefined) { if (!tournamentAllowsManualGroupAssignment(tournament as { groupsDistributedAt?: string | null })) { diff --git a/api/tournaments/[id].ts b/api/tournaments/[id].ts index 61f9c73..c37f15b 100644 --- a/api/tournaments/[id].ts +++ b/api/tournaments/[id].ts @@ -398,8 +398,13 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } if (action === 'rebalanceGroups') { - const result = await rebalanceTournamentTeams(db, id); - return corsRes.status(200).json(result); + try { + const result = await rebalanceTournamentTeams(db, id); + return corsRes.status(200).json(result); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Could not rebalance groups'; + return corsRes.status(400).json({ error: msg }); + } } if (action === 'start') { @@ -603,18 +608,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { if (!gid || !ObjectId.isValid(gid)) { return corsRes.status(400).json({ error: 'Invalid guestId' }); } + if (isTournamentStarted(cur as { startedAt?: unknown; phase?: unknown })) { + return corsRes.status(400).json({ error: 'Tournament already started' }); + } const r = await deleteGuestPlayer(db, id, gid); if (!r.ok) return corsRes.status(400).json({ error: r.error }); return corsRes.status(200).json({ ok: true }); } if (action === 'deleteAllGuestPlayers') { - const started = - !!(cur as { startedAt?: unknown }).startedAt || - (cur as { phase?: unknown }).phase === 'classification' || - (cur as { phase?: unknown }).phase === 'categories' || - (cur as { phase?: unknown }).phase === 'completed'; - if (started) { + if (isTournamentStarted(cur as { startedAt?: unknown; phase?: unknown })) { return corsRes.status(400).json({ error: 'Tournament already started' }); } const r = await deleteAllGuestPlayers(db, id); diff --git a/app/tournament/[id]/guest-players.tsx b/app/tournament/[id]/guest-players.tsx index 5d8f70c..861b7c6 100644 --- a/app/tournament/[id]/guest-players.tsx +++ b/app/tournament/[id]/guest-players.tsx @@ -39,6 +39,11 @@ export default function TournamentGuestPlayersScreen() { const { data: tournament, isLoading } = useTournament(id); const canManage = !!tournament && !!userId && ((tournament.organizerIds ?? []).includes(userId) || user?.role === 'admin'); + const tournamentStarted = + !!(tournament as { startedAt?: unknown } | undefined)?.startedAt || + (tournament as { phase?: unknown } | undefined)?.phase === 'classification' || + (tournament as { phase?: unknown } | undefined)?.phase === 'categories' || + (tournament as { phase?: unknown } | undefined)?.phase === 'completed'; const guests = tournament?.guestPlayers ?? []; const sortedGuests = useMemo( @@ -374,7 +379,7 @@ export default function TournamentGuestPlayersScreen() { guest={g} t={t} onEdit={() => router.push(`/tournament/${id}/guest-players?guestId=${gid}` as never)} - onDelete={() => confirmDeleteGuest(g)} + onDelete={tournamentStarted ? undefined : () => confirmDeleteGuest(g)} disabled={guestMutation.isPending} compact /> diff --git a/server/lib/knockoutAdvance.ts b/server/lib/knockoutAdvance.ts index d6311a3..36e6e46 100644 --- a/server/lib/knockoutAdvance.ts +++ b/server/lib/knockoutAdvance.ts @@ -73,42 +73,90 @@ export async function recomputeCategoryBracketAfterWinnerChange( const key = (x: unknown) => String(x ?? '').trim(); const idStr = (x: unknown) => String((x as any)?._id ?? '').trim(); - // Compute (winner, loser) for every completed match with a valid winner. - const winnerByMatchId = new Map(); - const loserByMatchId = new Map(); - for (const m of matches as any[]) { - if (key(m.status) !== 'completed') continue; + const docs = (matches as any[]).map((m) => ({ ...m })); + const docById = new Map(); + for (const m of docs) { const mid = idStr(m); - const w = key(m.winnerId); - const a = key(m.teamAId); - const b = key(m.teamBId); - if (!mid || !w || (w !== a && w !== b)) continue; - winnerByMatchId.set(mid, w); - loserByMatchId.set(mid, w === a ? b : a); + if (mid) docById.set(mid, m); } - const bulk: any[] = []; - for (const m of matches as any[]) { - const mid = idStr(m); - if (!mid) continue; - if (mid === editedMatchId) continue; + const resetFields = (m: any) => { + m.status = 'scheduled'; + delete m.winnerId; + delete m.pointsA; + delete m.pointsB; + delete m.setsWonA; + delete m.setsWonB; + delete m.startedAt; + delete m.completedAt; + delete m.durationSeconds; + delete m.scoreEvents; + delete m.lastPointAt; + delete m.refereeUserId; + delete m.refereeLockExpiresAt; + delete m.servingPlayerId; + delete m.serveIndex; + }; + + const winnerMaps = () => { + const winnerByMatchId = new Map(); + const loserByMatchId = new Map(); + for (const m of docs) { + if (key(m.status) !== 'completed') continue; + const mid = idStr(m); + const w = key(m.winnerId); + const a = key(m.teamAId); + const b = key(m.teamBId); + if (!mid || !w || (w !== a && w !== b)) continue; + winnerByMatchId.set(mid, w); + loserByMatchId.set(mid, w === a ? b : a); + } + return { winnerByMatchId, loserByMatchId }; + }; + + const dirty = new Set(); + let changed = true; + + while (changed) { + changed = false; + const { winnerByMatchId, loserByMatchId } = winnerMaps(); - const advAW = key(m.advanceTeamAFromMatchId); - const advBW = key(m.advanceTeamBFromMatchId); - const advAL = key(m.advanceTeamALoserFromMatchId); - const advBL = key(m.advanceTeamBLoserFromMatchId); + for (const m of docs) { + const mid = idStr(m); + if (!mid || mid === editedMatchId) continue; - const curA = key(m.teamAId); - const curB = key(m.teamBId); + const advAW = key(m.advanceTeamAFromMatchId); + const advBW = key(m.advanceTeamBFromMatchId); + const advAL = key(m.advanceTeamALoserFromMatchId); + const advBL = key(m.advanceTeamBLoserFromMatchId); - const desiredA = - advAW ? winnerByMatchId.get(advAW) ?? '' : advAL ? loserByMatchId.get(advAL) ?? '' : curA; - const desiredB = - advBW ? winnerByMatchId.get(advBW) ?? '' : advBL ? loserByMatchId.get(advBL) ?? '' : curB; + const curA = key(m.teamAId); + const curB = key(m.teamBId); - const slotChanged = desiredA !== curA || desiredB !== curB; - if (!slotChanged) continue; + const desiredA = + advAW ? winnerByMatchId.get(advAW) ?? '' : advAL ? loserByMatchId.get(advAL) ?? '' : curA; + const desiredB = + advBW ? winnerByMatchId.get(advBW) ?? '' : advBL ? loserByMatchId.get(advBL) ?? '' : curB; + const slotChanged = desiredA !== curA || desiredB !== curB; + if (!slotChanged) continue; + + if (desiredA) m.teamAId = desiredA; + else delete m.teamAId; + if (desiredB) m.teamBId = desiredB; + else delete m.teamBId; + resetFields(m); + dirty.add(mid); + changed = true; + } + } + + const bulk: any[] = []; + for (const mid of dirty) { + const m = docById.get(mid); + if (!m) continue; + const desiredA = key(m.teamAId); + const desiredB = key(m.teamBId); const $set: Record = { updatedAt: updatedAtIso, status: 'scheduled' }; const $unset: Record = { winnerId: '', diff --git a/server/lib/rebalanceTournamentTeams.ts b/server/lib/rebalanceTournamentTeams.ts index c79e67a..ac7a199 100644 --- a/server/lib/rebalanceTournamentTeams.ts +++ b/server/lib/rebalanceTournamentTeams.ts @@ -1,6 +1,7 @@ import type { Db } from 'mongodb'; import { ObjectId } from 'mongodb'; import { normalizeGroupCount, validateTournamentGroups } from '../../lib/tournamentGroups'; +import { isTournamentStarted } from '../../lib/isTournamentStarted'; /** * Round-robin assign groupIndex (0..groupCount-1) by createdAt so each group stays within capacity. @@ -10,9 +11,19 @@ export async function rebalanceTournamentTeams( tournamentId: string ): Promise<{ updated: number; teams: number }> { const tournamentsCol = db.collection('tournaments'); - const teamsCol = db.collection('teams'); const t = await tournamentsCol.findOne({ _id: new ObjectId(tournamentId) }); if (!t) throw new Error('Tournament not found'); + if (isTournamentStarted(t as { startedAt?: unknown; phase?: unknown })) { + throw new Error('Tournament has started'); + } + const locked = await db.collection('matches').countDocuments({ + tournamentId, + status: { $in: ['in_progress', 'completed'] }, + }); + if (locked > 0) { + throw new Error('Tournament has started'); + } + const teamsCol = db.collection('teams'); const maxT = Number((t as { maxTeams?: number }).maxTeams); const gc = normalizeGroupCount((t as { groupCount?: number }).groupCount); const vg = validateTournamentGroups(maxT, gc); diff --git a/tests/knockoutAdvance.test.ts b/tests/knockoutAdvance.test.ts new file mode 100644 index 0000000..69f2ce0 --- /dev/null +++ b/tests/knockoutAdvance.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import type { Db } from 'mongodb'; +import { recomputeCategoryBracketAfterWinnerChange } from '../server/lib/knockoutAdvance'; + +type MatchDoc = Record & { _id: string }; +type BulkUpdate = { + updateOne: { + filter: { _id: string }; + update: { + $set?: Record; + $unset?: Record; + }; + }; +}; + +function mockDbWithMatches(matches: MatchDoc[]) { + const bulkWrites: BulkUpdate[][] = []; + const db = { + collection(name: string) { + if (name !== 'matches') throw new Error(`Unexpected collection ${name}`); + return { + find() { + return { + toArray: async () => matches.map((m) => ({ ...m })), + }; + }, + bulkWrite: async (ops: BulkUpdate[]) => { + bulkWrites.push(ops); + for (const op of ops) { + const doc = matches.find((m) => m._id === op.updateOne.filter._id); + if (!doc) continue; + for (const [k, v] of Object.entries(op.updateOne.update.$set ?? {})) { + doc[k] = v; + } + for (const k of Object.keys(op.updateOne.update.$unset ?? {})) { + delete doc[k]; + } + } + return { modifiedCount: ops.length }; + }, + }; + }, + } as unknown as Db; + + return { db, bulkWrites }; +} + +describe('recomputeCategoryBracketAfterWinnerChange', () => { + it('resets completed grandchildren when an edited upstream winner invalidates their feeder', async () => { + const A = 'aaaaaaaaaaaaaaaaaaaaaaaa'; + const B = 'bbbbbbbbbbbbbbbbbbbbbbbb'; + const C = 'cccccccccccccccccccccccc'; + const D = 'dddddddddddddddddddddddd'; + const E = 'eeeeeeeeeeeeeeeeeeeeeeee'; + const F = 'ffffffffffffffffffffffff'; + const now = '2026-05-26T10:00:00.000Z'; + const matches: MatchDoc[] = [ + { + _id: 'qf1', + tournamentId: 't', + stage: 'category', + division: 'mixed', + category: 'Gold', + teamAId: A, + teamBId: B, + status: 'completed', + winnerId: A, + pointsA: 21, + pointsB: 19, + }, + { + _id: 'qf2', + tournamentId: 't', + stage: 'category', + division: 'mixed', + category: 'Gold', + teamAId: C, + teamBId: D, + status: 'completed', + winnerId: C, + pointsA: 21, + pointsB: 18, + }, + { + _id: 'sf1', + tournamentId: 't', + stage: 'category', + division: 'mixed', + category: 'Gold', + teamAId: B, + teamBId: C, + status: 'completed', + winnerId: B, + pointsA: 21, + pointsB: 17, + advanceTeamAFromMatchId: 'qf1', + advanceTeamBFromMatchId: 'qf2', + }, + { + _id: 'sf2', + tournamentId: 't', + stage: 'category', + division: 'mixed', + category: 'Gold', + teamAId: E, + teamBId: F, + status: 'completed', + winnerId: E, + pointsA: 21, + pointsB: 14, + }, + { + _id: 'final', + tournamentId: 't', + stage: 'category', + division: 'mixed', + category: 'Gold', + teamAId: B, + teamBId: E, + status: 'completed', + winnerId: B, + pointsA: 21, + pointsB: 16, + advanceTeamAFromMatchId: 'sf1', + advanceTeamBFromMatchId: 'sf2', + }, + ]; + const { db, bulkWrites } = mockDbWithMatches(matches); + + await recomputeCategoryBracketAfterWinnerChange(db, 't', 'mixed', 'Gold', now, 'qf1'); + + const sf1 = matches.find((m) => m._id === 'sf1'); + const final = matches.find((m) => m._id === 'final'); + expect(bulkWrites).toHaveLength(1); + expect(sf1).toMatchObject({ + teamAId: A, + teamBId: C, + status: 'scheduled', + updatedAt: now, + }); + expect(sf1?.winnerId).toBeUndefined(); + expect(sf1?.pointsA).toBeUndefined(); + expect(final).toMatchObject({ + teamBId: E, + status: 'scheduled', + updatedAt: now, + }); + expect(final?.teamAId).toBeUndefined(); + expect(final?.winnerId).toBeUndefined(); + expect(final?.pointsA).toBeUndefined(); + }); +}); diff --git a/tests/rebalanceTournamentTeams.test.ts b/tests/rebalanceTournamentTeams.test.ts new file mode 100644 index 0000000..20c5391 --- /dev/null +++ b/tests/rebalanceTournamentTeams.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import type { Db } from 'mongodb'; +import { rebalanceTournamentTeams } from '../server/lib/rebalanceTournamentTeams'; + +describe('rebalanceTournamentTeams', () => { + it('rejects group reassignment after the tournament has started', async () => { + let teamsTouched = false; + const db = { + collection(name: string) { + if (name === 'tournaments') { + return { + findOne: async () => ({ + _id: '507f1f77bcf86cd799439011', + phase: 'classification', + maxTeams: 4, + groupCount: 2, + }), + }; + } + if (name === 'teams') { + teamsTouched = true; + return { + find: () => ({ + sort: () => ({ + toArray: async () => [], + }), + }), + updateOne: async () => undefined, + }; + } + if (name === 'matches') { + return { countDocuments: async () => 0 }; + } + throw new Error(`Unexpected collection ${name}`); + }, + } as unknown as Db; + + await expect(rebalanceTournamentTeams(db, '507f1f77bcf86cd799439011')).rejects.toThrow( + 'Tournament has started' + ); + expect(teamsTouched).toBe(false); + }); +});