- 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>
962 lines
34 KiB
TypeScript
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" };
|
|
}
|