brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
Chris Parsons beab6400b2
Fix partial bracket scoring, rank labels, and Discord notification bugs (#158)
- NCAAM R64/R32 winners no longer receive T5 floor points; only Sweet
  Sixteen winners entering the first scoring round get a floor
- AFL T5-T6 and T7-T8 score as separate tiers (avg([5,6]) and avg([7,8]))
  instead of one shared avg([5-8]) pool
- Eliminated teams now always show their correct final rank (e.g. T33 for
  NCAAM R64 losers) even during partial round scoring, by deriving the
  rank label from the total match count per round rather than the dynamic
  still-alive count
- Discord notifications now fire when a drafted participant is eliminated
  with 0 points (e.g. R64 losers whose team score doesn't change)
- Discord notifications no longer include all prior matches — scoped to
  the current batch via matchIds
- Discord section headers have spacing; rank medals replaced with numbers
- Rounds auto-complete when all matches are marked done; manual Complete
  Round dropdown removed
- Fixed double Discord notification when the last match in a round
  triggers auto-complete: processPlayoffEvent now accepts skipRecalculate
  so the caller controls when the notification fires
- Fixed falsy check on finalPosition=0 in calculateTeamScore
- Undrafted 0-point participants filtered from Eliminated Teams display

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 12:34:10 -07:00

962 lines
34 KiB
TypeScript

import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { redirect } from "react-router";
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,
} 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,
} from "~/models/scoring-calculator";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
import {
createGroupsForEvent,
addMembersToGroup,
findGroupsByEventId,
toggleMemberEliminated,
getAdvancingParticipantIds,
getEliminatedParticipantIds,
} from "~/models/tournament-group";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } 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 if this event has a group-stage template
const tournamentGroups = await findGroupsByEventId(params.eventId);
// 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<typeof matches[0] & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
loser: { 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
const { setParticipantResult } = await import("~/models/participant-result");
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated
);
}
}
console.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) {
console.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
* non-bracket eliminations 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<typeof database>
): Promise<void> {
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 });
console.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
if (event.bracketTemplateId) {
const template = getBracketTemplate(event.bracketTemplateId);
if (template) {
try {
await advanceWinnerTemplate(matchId, winnerId, template);
} catch (error) {
// Only ignore "already filled" errors, re-throw unexpected errors
if (
error instanceof Error &&
error.message.includes("already filled")
) {
console.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).
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: match.isScoring ?? true,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventId: event.id,
eventName: event.name ?? undefined,
matchId,
});
const db = database();
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
return { success: "Winner set successfully" };
} catch (error) {
console.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;
// 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")
) {
console.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.
// isScoring ?? true: default to true for legacy rows pre-dating the isScoring column.
await processMatchResult({
round: match.round,
winnerId,
loserId,
isScoring: match.isScoring ?? true,
sportsSeasonId: event.sportsSeasonId,
bracketTemplateId: event.bracketTemplateId,
eventName: event.name ?? undefined,
skipSideEffects: true,
});
successCount++;
processedMatchIds.push(matchId);
} catch (error) {
console.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) {
console.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) {
console.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 { findParticipantResultsBySportsSeasonId } = await import(
"~/models/participant-result"
);
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 { findSeasonSportsBySportsSeasonId } = await import(
"~/models/season-sport"
);
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
await processPlayoffEvent(params.eventId);
return {
success: `${round} completed and placements calculated successfully`,
};
} catch (error) {
console.error("Error completing round:", error);
return {
error:
error instanceof Error ? error.message : "Failed to complete round",
};
}
}
if (intent === "reprocess-eliminations") {
try {
// Get the event
const event = await getScoringEventById(params.eventId);
if (!event) {
return { error: "Event not found" };
}
// Get all matches in the bracket
const matches = await findPlayoffMatchesByEventId(params.eventId);
if (matches.length === 0) {
return { error: "No bracket exists for this event" };
}
// Get all participants in the sports season
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Collect all participant IDs that appear in ANY match
const participantsInBracket = new Set<string>();
for (const match of matches) {
if (match.participant1Id) participantsInBracket.add(match.participant1Id);
if (match.participant2Id) participantsInBracket.add(match.participant2Id);
}
// Mark participants NOT in the bracket as eliminated
const { setParticipantResult } = await import("~/models/participant-result");
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
await setParticipantResult(
participant.id,
event.sportsSeasonId,
0 // 0 = didn't make playoffs, eliminated
);
eliminatedCount++;
}
}
console.log(`[ReprocessEliminations] Marked ${eliminatedCount} participants as eliminated`);
return {
success: `Successfully marked ${eliminatedCount} participant(s) as eliminated`,
};
} catch (error) {
console.error("Error reprocessing eliminations:", error);
return {
error:
error instanceof Error ? error.message : "Failed to reprocess eliminations",
};
}
}
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" };
}
// Get template to determine round order
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 { findParticipantsBySportsSeasonId } = await import("~/models/participant");
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
// Get participants in matches
const participantsInMatches = new Set(
matches.flatMap((m) => [m.participant1Id, m.participant2Id].filter(Boolean))
);
// Process all rounds in order
const db = database();
for (const round of template.rounds) {
const roundMatches = matches.filter((m) => m.round === round.name);
if (roundMatches.length === 0) continue;
// Set playoffRound and process this round
await db
.update(schema.scoringEvents)
.set({ playoffRound: round.name, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, params.eventId));
await processPlayoffEvent(params.eventId, db);
}
// Assign 0 points to participants not in the bracket (Q20)
const { setParticipantResult } = await import("~/models/participant-result");
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 { recalculateStandings } = await import("~/models/scoring-calculator");
const { findSeasonSportsBySportsSeasonId } = await import("~/models/season-sport");
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
for (const seasonSport of seasonSports) {
await recalculateStandings(seasonSport.seasonId, db);
}
return {
success: `Bracket finalized! All placements calculated and standings updated.`,
};
} catch (error) {
console.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)
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);
}
// Generate the empty knockout bracket structure
await generateBracketFromTemplate(params.eventId, templateId);
return { success: "Groups and knockout bracket structure created successfully" };
} catch (error) {
console.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) {
console.error("Error toggling elimination:", error);
return {
error: error instanceof Error ? error.message : "Failed to toggle elimination",
};
}
}
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);
const { setParticipantResult } = await import("~/models/participant-result");
for (const participantId of eliminatedIds) {
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
console.log(
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
);
return { success: "Knockout bracket populated successfully" };
} catch (error) {
console.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" };
}