import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { findSportsSeasonById } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event"; import { findPlayoffMatchesByEventId, generateBracketFromTemplate, setMatchWinner, advanceWinnerTemplate, findPlayoffMatchById, assignParticipantsToKnockout, doesLoserAdvance, } from "~/models/playoff-match"; import { createGame, updateGame, deleteGame, type PlayoffMatchGameStatus, } from "~/models/playoff-match-game"; import { upsertMatchOdds, deleteOddsForParticipant, } from "~/models/playoff-match-odds"; import { processPlayoffEvent, processMatchResult, recalculateAffectedLeagues, recalculateStandings, } from "~/models/scoring-calculator"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates"; import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport"; import { createDailySnapshot } from "~/models/standings"; import { createGroupsForEvent, addMembersToGroup, findGroupsByEventId, toggleMemberEliminated, getAdvancingParticipantIds, getEliminatedParticipantIds, } from "~/models/tournament-group"; import { createGroupStageMatches, generateRoundRobinPairings, updateGroupStageMatchResult, updateGroupStageMatchSchedule, findMatchesByGroupId, } from "~/models/group-stage-match"; import { logger } from "~/lib/logger"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; export async function loader({ params }: Route.LoaderArgs) { const sportsSeason = await findSportsSeasonById(params.id); if (!sportsSeason) { throw new Response("Sports season not found", { status: 404 }); } const event = await getScoringEventById(params.eventId); if (!event) { throw new Response("Event not found", { status: 404 }); } if (event.eventType !== "playoff_game") { throw new Response("This event is not a playoff event", { status: 400 }); } const participants = await findParticipantsBySportsSeasonId(params.id); const matches = await findPlayoffMatchesByEventId(params.eventId); // Fetch tournament groups with their round-robin matches const tournamentGroupsRaw = await findGroupsByEventId(params.eventId); const tournamentGroups = await Promise.all( tournamentGroupsRaw.map(async (group) => ({ ...group, groupMatches: await findMatchesByGroupId(group.id), })) ); // The matches already include participant relations from the model query return { sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; }, event, participants, matches: matches as Array; odds: Array<{ id: string; participantId: string; moneylineOdds: number; impliedProbability: string; oddsSource?: string | null; participant: { id: string; name: string } | null }>; }>, tournamentGroups, }; } export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); if (intent === "generate-bracket") { const templateId = formData.get("templateId"); if (typeof templateId !== "string" || !templateId) { return { error: "Template ID is required" }; } const template = getBracketTemplate(templateId); if (!template) { return { error: "Invalid bracket template" }; } // Parse per-event region config for NCAA-style brackets let regionOverride: BracketRegion[] | undefined; if (template.regions && template.regions.length > 0) { const regions: BracketRegion[] = []; for (let r = 0; r < template.regions.length; r++) { const name = (formData.get(`regionName${r}`) as string | null)?.trim() || `Region ${r + 1}`; const seedsRaw = (formData.get(`regionPlayInSeeds${r}`) as string | null) || ""; const playInSeeds = seedsRaw .split(",") .map((s) => parseInt(s.trim(), 10)) .filter((n) => !isNaN(n) && n >= 1 && n <= 16); const playInSet = new Set(playInSeeds); regions.push({ name, directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)), playIns: playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })), }); } // Validate total = template.totalTeams const total = regions.reduce( (sum, r) => sum + r.directSeeds.length + r.playIns.length * 2, 0 ); if (total !== template.totalTeams) { return { error: `Region config accounts for ${total} team slots but template requires ${template.totalTeams}`, }; } regionOverride = regions; } const participantIds: string[] = []; for (let i = 0; i < template.totalTeams; i++) { const participantId = formData.get(`participant${i}`); if (typeof participantId !== "string" || !participantId) { return { error: `Participant ${i + 1} is required` }; } participantIds.push(participantId); } // Check for duplicates const uniqueParticipants = new Set(participantIds); if (uniqueParticipants.size !== participantIds.length) { return { error: "Each participant can only be selected once" }; } try { await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride); // PHASE 5.3: Mark participants NOT in the bracket as eliminated const event = await getScoringEventById(params.eventId); if (event) { // Get all participants in the sports season const allParticipants = await findParticipantsBySportsSeasonId(params.id); // Create a set of participants in the bracket for fast lookup const participantsInBracket = new Set(participantIds); // Mark participants NOT in the bracket as eliminated for (const participant of allParticipants) { if (!participantsInBracket.has(participant.id)) { await setParticipantResult( participant.id, event.sportsSeasonId, 0 // 0 = didn't make playoffs, eliminated ); } } logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`); } // Update the event to store the template ID, scoring start round, and region config await updateScoringEvent(params.eventId, { bracketTemplateId: templateId, scoringStartsAtRound: template.scoringStartsAtRound, bracketRegionConfig: regionOverride, }); return { success: "Bracket generated successfully" }; } catch (error) { logger.error("Error generating bracket:", error); return { error: error instanceof Error ? error.message : "Failed to generate bracket", }; } } /** * Auto-complete a round when every match in it is marked complete. * Updates scoringEvents.playoffRound and calls processPlayoffEvent so that * round placements are recorded and probabilities are refreshed. * This replaces the manual "Complete Round" button. * * skipRecalculate=true because the caller already sent a Discord notification * scoped to the current batch of matches — we don't want a second one that * would show all previously-completed matches in the event. */ async function autoCompleteRoundIfDone( eventId: string, round: string, sportsSeasonId: string, db: ReturnType ): Promise { const allMatches = await findPlayoffMatchesByEventId(eventId); const roundMatches = allMatches.filter((m) => m.round === round); if (roundMatches.length === 0) return; const allDone = roundMatches.every((m) => m.isComplete); if (!allDone) return; await db .update(schema.scoringEvents) .set({ playoffRound: round, updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, eventId)); await processPlayoffEvent(eventId, db, { skipRecalculate: true }); logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`); } if (intent === "set-winner") { const matchId = formData.get("matchId"); const winnerId = formData.get("winnerId"); if (typeof matchId !== "string" || !matchId) { return { error: "Match ID is required" }; } if (typeof winnerId !== "string" || !winnerId) { return { error: "Winner ID is required" }; } try { const match = await findPlayoffMatchById(matchId); if (!match) { return { error: "Match not found" }; } // Get the event to determine the template const event = await getScoringEventById(match.scoringEventId); if (!event) { return { error: "Event not found" }; } // Determine loser const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id; if (!loserId) { return { error: "Could not determine loser" }; } // Set the winner await setMatchWinner(matchId, winnerId, loserId); // Try to advance the winner to the next round using template const setWinnerTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; if (setWinnerTemplate) { try { await advanceWinnerTemplate(matchId, winnerId, setWinnerTemplate); } catch (error) { // Only ignore "already filled" errors, re-throw unexpected errors if ( error instanceof Error && error.message.includes("already filled") ) { logger.warn("Match already filled, skipping advancement"); } else { throw error; // Re-throw unexpected errors } } } // Immediately score this match: loser gets their final placement, // winner gets provisional floor points (isPartialScore=true). // Prefer the template-defined isScoring over the DB field: the DB column // defaults to true, so legacy play-in rows may be incorrectly marked as scoring. const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring; await processMatchResult({ round: match.round, winnerId, loserId, isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true), sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, matchId, loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), }); const db = database(); await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); return { success: "Winner set successfully" }; } catch (error) { logger.error("Error setting winner:", error); return { error: error instanceof Error ? error.message : "Failed to set winner", }; } } if (intent === "set-round-winners") { const round = formData.get("round"); if (typeof round !== "string" || !round) { return { error: "Round is required" }; } try { // Get all winner assignments from form data const winnerAssignments: Array<{ matchId: string; winnerId: string }> = []; for (const [key, value] of formData.entries()) { if (key.startsWith("winner-") && typeof value === "string") { const matchId = key.replace("winner-", ""); winnerAssignments.push({ matchId, winnerId: value }); } } if (winnerAssignments.length === 0) { return { error: "No winners selected" }; } // Get the event to determine the template const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; // Prefer template-defined isScoring over the DB field (DB defaults to true, // so legacy play-in rows may be incorrectly marked as scoring). const roundIsScoring = new Map( template?.rounds.map((r) => [r.name, r.isScoring]) ?? [] ); // Process each winner assignment let successCount = 0; const errors: string[] = []; const processedMatchIds: string[] = []; for (const { matchId, winnerId } of winnerAssignments) { try { const match = await findPlayoffMatchById(matchId); if (!match) { errors.push(`Match ${matchId} not found`); continue; } // Guard: ensure the match actually belongs to the submitted round. if (match.round !== round) { errors.push(`Match ${match.matchNumber} is in round "${match.round}", not "${round}"`); continue; } // Determine loser const loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id; if (!loserId) { errors.push(`Could not determine loser for match ${match.matchNumber}`); continue; } // Set the winner await setMatchWinner(matchId, winnerId, loserId); // Try to advance the winner to the next round using template if (template) { try { await advanceWinnerTemplate(matchId, winnerId, template); } catch (error) { // Only ignore "already filled" errors if ( error instanceof Error && error.message.includes("already filled") ) { logger.warn("Match already filled, skipping advancement"); } else { throw error; } } } // Score this match without triggering standings/probability recalc yet — // we batch those side effects into a single call after the loop. await processMatchResult({ round: match.round, winnerId, loserId, isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true), sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, matchId, skipSideEffects: true, loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), }); successCount++; processedMatchIds.push(matchId); } catch (error) { logger.error(`Error setting winner for match ${matchId}:`, error); errors.push( `Match ${matchId}: ${error instanceof Error ? error.message : "Unknown error"}` ); } } // Run side effects once for the whole batch (avoids N redundant recalcs). // Pass matchIds so Discord only shows the matches from this submission, not all // previously completed matches in the event. if (successCount > 0) { const db = database(); await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); } catch (error) { logger.error(`Error updating probabilities after batch round winners:`, error); } await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } if (errors.length > 0 && successCount === 0) { return { error: `Failed to set winners: ${errors.join(", ")}` }; } if (errors.length > 0) { return { success: `Set ${successCount} winner(s) successfully`, error: `Some errors occurred: ${errors.join(", ")}`, }; } return { success: `Successfully set ${successCount} winner(s) for ${round}` }; } catch (error) { logger.error("Error setting round winners:", error); return { error: error instanceof Error ? error.message : "Failed to set winners", }; } } if (intent === "complete-round") { const round = formData.get("round"); if (typeof round !== "string" || !round) { return { error: "Round is required" }; } try { // Get the event and update playoffRound to the completed round const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } // Verify all matches in this round are complete const matches = await findPlayoffMatchesByEventId(params.eventId); // Validate that the round exists in this event's matches const roundMatches = matches.filter((m) => m.round === round); if (roundMatches.length === 0) { return { error: `Round "${round}" not found in this bracket` }; } const allComplete = roundMatches.every((m) => m.isComplete); if (!allComplete) { return { error: `Not all matches in ${round} are complete` }; } // Validate round order: ensure previous rounds are complete const existingResults = await findParticipantResultsBySportsSeasonId( params.id ); if (round === "Finals") { // Finals requires Semifinals to be complete (3rd/4th place results exist) const hasSemifinalResults = existingResults.some( (r) => r.finalPosition === 3 || r.finalPosition === 4 ); if (!hasSemifinalResults) { return { error: "Semifinals must be completed before Finals (no 3rd/4th place results found)", }; } } else if (round === "Semifinals") { // Semifinals requires Quarterfinals to be complete if QF matches exist const hasQuarterfinals = matches.some((m) => m.round === "Quarterfinals"); if (hasQuarterfinals) { const hasQuarterfinalsResults = existingResults.some( (r) => r.finalPosition === 5 || r.finalPosition === 6 || r.finalPosition === 7 || r.finalPosition === 8 ); if (!hasQuarterfinalsResults) { return { error: "Quarterfinals must be completed before Semifinals (no 5th-8th place results found)", }; } } } // Get the sports season to find a fantasy season for scoring rules const sportsSeason = await findSportsSeasonById(params.id); if (!sportsSeason) { return { error: "Sports season not found" }; } // Process the playoff event to calculate placements // TODO Phase 2.7: Refactor processPlayoffEvent to use template system // For now, we still need to set playoffRound for the old scoring logic // even though we removed it from the create event UI const db = database(); await db .update(schema.scoringEvents) .set({ playoffRound: round, updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, params.eventId)); // Get a fantasy season ID for scoring calculation const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); if (seasonSports.length === 0) { return { error: "No fantasy seasons found for this sports season", }; } // Process playoff event to update participant results. // skipRecalculate=true: match winners were already set via set-winner/set-round-winners, // which triggered recalculateAffectedLeagues and Discord per match. No need to re-fire. await processPlayoffEvent(params.eventId, undefined, { skipRecalculate: true }); return { success: `${round} completed and placements calculated successfully`, }; } catch (error) { logger.error("Error completing round:", error); return { error: error instanceof Error ? error.message : "Failed to complete round", }; } } if (intent === "reprocess-bracket") { try { const event = await getScoringEventById(params.eventId); if (!event) return { error: "Event not found" }; const matches = await findPlayoffMatchesByEventId(params.eventId); const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId); if (completed.length === 0) { return { error: "No completed matches to reprocess" }; } // Delete ALL results for this sports season and rebuild from scratch. // Only deleting partial rows leaves stale finalized rows that block // the "never un-finalize" guard in upsertParticipantResult. const db = database(); await db .delete(schema.participantResults) .where( eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId) ); // Replay each completed match in bracket order (earlier rounds first). const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; const roundOrder = template ? template.rounds.map((r) => r.name) : []; // Build a round→isScoring map from the template so we use the template as the // source of truth rather than the DB column (which defaults to true and may be // wrong for brackets created before the isScoring column was added). const templateRoundIsScoring = new Map( template?.rounds.map((r) => [r.name, r.isScoring]) ?? [] ); const sortedMatches = completed.toSorted((a, b) => { const ai = roundOrder.indexOf(a.round); const bi = roundOrder.indexOf(b.round); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); }); for (const match of sortedMatches) { if (!match.winnerId || !match.loserId) continue; const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true); await processMatchResult( { round: match.round, winnerId: match.winnerId, loserId: match.loserId, isScoring, sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, matchId: match.id, skipSideEffects: true, loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), } ); } // Mark participants NOT in any bracket match as eliminated (finalPosition = 0). // This covers teams that didn't make the playoffs/play-in tournament. const allParticipants = await findParticipantsBySportsSeasonId(params.id); const bracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) bracketParticipantIds.add(match.participant1Id); if (match.participant2Id) bracketParticipantIds.add(match.participant2Id); } let eliminatedCount = 0; for (const participant of allParticipants) { if (!bracketParticipantIds.has(participant.id)) { await setParticipantResult( participant.id, event.sportsSeasonId, 0 ); eliminatedCount++; } } // skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement. await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true }); return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` }; } catch (error) { logger.error("Error reprocessing bracket:", error); return { error: error instanceof Error ? error.message : "Failed to reprocess bracket", }; } } if (intent === "finalize-bracket") { try { // Get the event const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } // Get all matches const matches = await findPlayoffMatchesByEventId(params.eventId); if (matches.length === 0) { return { error: "No bracket exists for this event" }; } // Verify the event has a valid bracket template configured const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; if (!template) { return { error: "Bracket template not found" }; } // Verify ALL matches are complete const incompleteMatches = matches.filter((m) => !m.isComplete); if (incompleteMatches.length > 0) { return { error: `Cannot finalize: ${incompleteMatches.length} match(es) still incomplete`, }; } // Get all participants in this sports season const allParticipants = await findParticipantsBySportsSeasonId(params.id); // Get participants in matches const participantsInMatches = new Set( matches.flatMap((m) => [m.participant1Id, m.participant2Id].filter(Boolean)) ); const db = database(); // Assign 0 points to participants not in the bracket (Q20) for (const participant of allParticipants) { if (!participantsInMatches.has(participant.id)) { await setParticipantResult( participant.id, params.id, 0 // 0 placement = 0 points ); } } // Mark event as complete await db .update(schema.scoringEvents) .set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, params.eventId)); // Recalculate standings for all affected fantasy seasons const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); for (const seasonSport of seasonSports) { await recalculateStandings(seasonSport.seasonId, db); await createDailySnapshot(seasonSport.seasonId, db); } return { success: `Bracket finalized! All placements calculated and standings updated.`, }; } catch (error) { logger.error("Error finalizing bracket:", error); return { error: error instanceof Error ? error.message : "Failed to finalize bracket", }; } } if (intent === "generate-groups") { const templateId = formData.get("templateId"); if (typeof templateId !== "string" || !templateId) { return { error: "Template ID is required" }; } const template = getBracketTemplate(templateId); if (!template || !template.groupStage) { return { error: "Invalid template or template has no group stage" }; } const { groupStage } = template; const totalTeams = groupStage.groupCount * groupStage.teamsPerGroup; // Collect participant IDs from form const participantIds: string[] = []; for (let i = 0; i < totalTeams; i++) { const participantId = formData.get(`participant${i}`); if (typeof participantId !== "string" || !participantId) { return { error: `Participant ${i + 1} is required` }; } participantIds.push(participantId); } // Check for duplicates const uniqueParticipants = new Set(participantIds); if (uniqueParticipants.size !== participantIds.length) { return { error: "Each participant can only be selected once" }; } try { // Update the event with template info await updateScoringEvent(params.eventId, { bracketTemplateId: templateId, scoringStartsAtRound: template.scoringStartsAtRound, }); // Create tournament groups const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels); // Distribute participants into groups (in selection order) and create round-robin matches for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { const startIdx = groupIndex * groupStage.teamsPerGroup; const groupParticipantIds = participantIds.slice( startIdx, startIdx + groupStage.teamsPerGroup ); await addMembersToGroup(groups[groupIndex].id, groupParticipantIds); // Create the 6 round-robin match pairings for this group if (groupParticipantIds.length === 4) { const pairings = generateRoundRobinPairings(groupParticipantIds); await createGroupStageMatches(groups[groupIndex].id, pairings); } } // Generate the empty knockout bracket structure await generateBracketFromTemplate(params.eventId, templateId); // Eliminate participants from this sport season who are not in any group const allParticipants = await findParticipantsBySportsSeasonId(params.id); let eliminatedCount = 0; for (const participant of allParticipants) { if (!uniqueParticipants.has(participant.id)) { await setParticipantResult(participant.id, params.id, 0); eliminatedCount++; } } return { success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`, }; } catch (error) { logger.error("Error generating groups:", error); return { error: error instanceof Error ? error.message : "Failed to generate groups", }; } } if (intent === "toggle-elimination") { const memberId = formData.get("memberId"); if (typeof memberId !== "string" || !memberId) { return { error: "Member ID is required" }; } try { const updated = await toggleMemberEliminated(memberId); return { success: updated.eliminated ? "Team marked as eliminated" : "Team reinstated", }; } catch (error) { logger.error("Error toggling elimination:", error); return { error: error instanceof Error ? error.message : "Failed to toggle elimination", }; } } if (intent === "update-group-match") { const matchId = formData.get("matchId"); const p1Score = formData.get("participant1Score"); const p2Score = formData.get("participant2Score"); if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" }; if (typeof p1Score !== "string" || typeof p2Score !== "string") { return { error: "Both scores are required" }; } const s1 = parseInt(p1Score, 10); const s2 = parseInt(p2Score, 10); if (isNaN(s1) || isNaN(s2) || s1 < 0 || s2 < 0) { return { error: "Scores must be non-negative integers" }; } try { await updateGroupStageMatchResult(matchId, s1, s2); return { success: "Match result saved" }; } catch (error) { logger.error("Error updating group match:", error); return { error: error instanceof Error ? error.message : "Failed to update match" }; } } if (intent === "update-group-match-schedule") { const matchId = formData.get("matchId"); const scheduledAt = formData.get("scheduledAt"); if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" }; try { const dateValue = typeof scheduledAt === "string" && scheduledAt ? new Date(scheduledAt) : null; await updateGroupStageMatchSchedule(matchId, dateValue); return { success: "Match time saved" }; } catch (error) { logger.error("Error updating group match schedule:", error); return { error: error instanceof Error ? error.message : "Failed to update schedule" }; } } if (intent === "populate-knockout") { try { const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } // Parse match assignments from form const assignments: Array<{ matchNumber: number; slot: "participant1Id" | "participant2Id"; participantId: string; }> = []; for (const [key, value] of formData.entries()) { const matchPattern = /^match-(\d+)-(participant1Id|participant2Id)$/; const match = key.match(matchPattern); if (match && typeof value === "string" && value) { assignments.push({ matchNumber: parseInt(match[1], 10), slot: match[2] as "participant1Id" | "participant2Id", participantId: value, }); } } if (assignments.length !== 32) { return { error: `Expected 32 assignments but got ${assignments.length}` }; } // Validate all assignments are unique participants const assignedParticipantIds = new Set(assignments.map((a) => a.participantId)); if (assignedParticipantIds.size !== 32) { return { error: "All 32 knockout slots must have unique participants" }; } // Validate all assigned participants are non-eliminated const advancingIds = new Set(await getAdvancingParticipantIds(params.eventId)); for (const participantId of assignedParticipantIds) { if (!advancingIds.has(participantId)) { return { error: "All assigned participants must be non-eliminated group members" }; } } // Assign participants to knockout bracket await assignParticipantsToKnockout(params.eventId, assignments); // Mark eliminated group participants with finalPosition = 0 const eliminatedIds = await getEliminatedParticipantIds(params.eventId); for (const participantId of eliminatedIds) { await setParticipantResult(participantId, event.sportsSeasonId, 0); } logger.log( `[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated` ); return { success: "Knockout bracket populated successfully" }; } catch (error) { logger.error("Error populating knockout:", error); return { error: error instanceof Error ? error.message : "Failed to populate knockout", }; } } // ── Game scheduling ────────────────────────────────────────────────────── if (intent === "add-game") { const matchId = formData.get("matchId"); const gameNumber = formData.get("gameNumber"); const scheduledAt = formData.get("scheduledAt"); const notes = formData.get("notes"); if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" }; if (typeof gameNumber !== "string" || !gameNumber) return { error: "gameNumber is required" }; try { await createGame({ playoffMatchId: matchId, gameNumber: parseInt(gameNumber, 10), scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null, notes: notes ? (notes as string) : null, }); return { success: "Game added" }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to add game" }; } } if (intent === "update-game") { const gameId = formData.get("gameId"); const scheduledAt = formData.get("scheduledAt"); const status = formData.get("status"); const participant1Score = formData.get("participant1Score"); const participant2Score = formData.get("participant2Score"); const winnerId = formData.get("winnerId"); const notes = formData.get("notes"); if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" }; const validStatuses: PlayoffMatchGameStatus[] = ["scheduled", "complete", "postponed"]; if (status && !validStatuses.includes(status as PlayoffMatchGameStatus)) { return { error: `Invalid status: must be one of ${validStatuses.join(", ")}` }; } try { const updated = await updateGame(gameId, { ...(scheduledAt !== null && { scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null }), ...(status && { status: status as PlayoffMatchGameStatus }), ...(participant1Score !== null && { participant1Score: (participant1Score as string) || null }), ...(participant2Score !== null && { participant2Score: (participant2Score as string) || null }), ...(winnerId !== null && { winnerId: (winnerId as string) || null }), ...(notes !== null && { notes: (notes as string) || null }), }); if (!updated) return { error: "Game not found" }; return { success: "Game updated" }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to update game" }; } } if (intent === "delete-game") { const gameId = formData.get("gameId"); if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" }; try { await deleteGame(gameId); return { success: "Game deleted" }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to delete game" }; } } // ── Odds management ────────────────────────────────────────────────────── if (intent === "upsert-odds") { const matchId = formData.get("matchId"); const participantId = formData.get("participantId"); const moneylineOdds = formData.get("moneylineOdds"); const oddsSource = formData.get("oddsSource"); if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" }; if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" }; if (typeof moneylineOdds !== "string" || !moneylineOdds) return { error: "moneylineOdds is required" }; const moneylineInt = parseInt(moneylineOdds, 10); if (isNaN(moneylineInt)) return { error: "moneylineOdds must be an integer" }; try { await upsertMatchOdds(matchId, participantId, { moneylineOdds: moneylineInt, oddsSource: oddsSource ? (oddsSource as string) : undefined, }); return { success: "Odds updated" }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to update odds" }; } } if (intent === "delete-odds") { const matchId = formData.get("matchId"); const participantId = formData.get("participantId"); if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" }; if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" }; try { await deleteOddsForParticipant(matchId, participantId); return { success: "Odds deleted" }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to delete odds" }; } } return { error: "Invalid action" }; }