* Emit standings-updated socket event after recalculate-floors so league pages refresh automatically When the admin runs "Recalculate Floors" on the bracket page, the league sports-season homepage was showing stale elimination data because there was no mechanism to notify it of the change. Fix: after recalculate-floors updates participant_results, emit a standings-updated socket event to all fantasy-season draft rooms linked to the sports season. The sports-season page now joins its draft room and revalidates its loader whenever it receives that event for the matching sports season. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix recalculate-floors incorrectly eliminating play-in losers who still advance The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix isScoring fallback and loserAdvances gaps across all match-processing paths Three issues found in code review around the recalculate-floors fix: 1. set-winner and set-round-winners (bracket.server.ts) used `match.isScoring ?? true` just like recalculate-floors did. Both now derive isScoring from the bracket template as the source of truth, falling back to the DB field only when the round isn't defined in the template. 2. processPlayoffEvent (scoring-calculator.ts) used `matches[0]?.isScoring ?? true` with the same DB-default problem. Now uses BRACKET_TEMPLATES[bracketTemplateId] to look up the round's isScoring before falling back to the DB field. 3. doesLoserAdvance (playoff-match.ts) was missing the AFL afl_10 Qualifying Finals case: both losers advance to Semi-Finals. Without this, AFL QF losers would incorrectly receive finalPosition=0 when processed through the non-scoring path. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB * Fix no-non-null-assertion lint errors in isScoring Map lookups Replace Map.has(key) ? Map.get(key)! : fallback pattern with Map.get(key) ?? fallback to satisfy oxlint no-non-null-assertion rule. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB --------- Co-authored-by: Claude <noreply@anthropic.com>
1097 lines
40 KiB
TypeScript
1097 lines
40 KiB
TypeScript
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<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;
|
|
games: Array<{ id: string; gameNumber: number; scheduledAt: Date | null; status: string; winner?: { name: string } | null }>;
|
|
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<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 });
|
|
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<string, boolean>(
|
|
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,
|
|
eventName: event.name ?? undefined,
|
|
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 === "recalculate-floors") {
|
|
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<string, boolean>(
|
|
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) {
|
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
|
// defaults to true, so legacy play-in rows that predate the column are
|
|
// incorrectly marked as scoring and would assign finalPosition=0 to losers
|
|
// who still have a second game (e.g. NBA 7v8 play-in loser → Round 2).
|
|
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,
|
|
skipSideEffects: true,
|
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
|
}
|
|
);
|
|
}
|
|
|
|
// skipDiscord: recalculate-floors is a data-correction tool, not a result announcement.
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
|
|
|
return { success: `Recalculated floors from ${sortedMatches.length} completed match(es)` };
|
|
} catch (error) {
|
|
logger.error("Error recalculating floors:", error);
|
|
return {
|
|
error: error instanceof Error ? error.message : "Failed to recalculate floors",
|
|
};
|
|
}
|
|
}
|
|
|
|
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
|
|
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++;
|
|
}
|
|
}
|
|
|
|
logger.log(`[ReprocessEliminations] Marked ${eliminatedCount} participants as eliminated`);
|
|
|
|
return {
|
|
success: `Successfully marked ${eliminatedCount} participant(s) as eliminated`,
|
|
};
|
|
} catch (error) {
|
|
logger.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" };
|
|
}
|
|
|
|
// 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" };
|
|
}
|