import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket"; import { findSportsSeasonById } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId, createParticipant, updateParticipant, } from "~/models/season-participant"; import { getScoringEventById, updateScoringEvent, isReadOnlySibling } 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, processQualifyingBracketEvent, processQualifyingEvent, finalizeQualifyingPoints, recalculateAffectedLeagues, recalculateStandings, autoCompleteRoundIfDone, } from "~/models/scoring-calculator"; import { updateProbabilitiesAfterResult } from "~/services/probability-updater"; import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates"; import { setParticipantResult, findParticipantResultsBySportsSeasonId, deleteParticipantResultsBySportsSeasonId, } 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"; import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results"; import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync"; import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis"; import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers"; 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" && event.eventType !== "major_tournament") { throw new Response("This event is not a playoff event", { status: 400 }); } const participants = await findParticipantsBySportsSeasonId(params.id); const matches = await findPlayoffMatchesByEventId(params.eventId); // Fetch tournament groups with their round-robin matches const tournamentGroupsRaw = await findGroupsByEventId(params.eventId); const tournamentGroups = await Promise.all( tournamentGroupsRaw.map(async (group) => ({ ...group, groupMatches: await findMatchesByGroupId(group.id), })) ); // The matches already include participant relations from the model query return { sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string }; }, event, participants, matches: matches as Array; odds: Array<{ id: string; participantId: string; moneylineOdds: number; impliedProbability: string; oddsSource?: string | null; participant: { id: string; name: string } | null }>; }>, tournamentGroups, }; } /** * Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's * guaranteed-minimum QP from the bracket and refresh affected league standings. * * Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never * writes seasonParticipantResults and never records team_score_events — match * results surface via the QP Standings and the Discord standings update. Final * fantasy placements come from finalizeQualifyingPoints across all majors. */ async function scoreQualifyingBracket( event: { id: string; sportsSeasonId: string; name: string | null; isPrimary: boolean; tournamentId: string | null; }, db: ReturnType, recalcOptions?: Parameters[2], /** * Season_participant ids of players knocked out for the first time by this * operation (losers of matches that just reached completion). Forwarded to the * fan-out so every mirror window announces the "Knocked Out" section — a * non-scoring-round exit earns no QP and is otherwise invisible to the mirror. * Empty for re-scores/reprocesses, which decide no new losers. */ newlyEliminatedParticipantIds?: Set ): Promise { await processQualifyingBracketEvent(event.id, db); await recalculateAffectedLeagues( event.sportsSeasonId, db, recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined } ); // If this is the shared major's primary window, propagate to siblings. // Mid-tournament (a single round): don't mark complete yet. await fanOutMajorIfPrimary(event, { markComplete: false, newlyEliminatedParticipantIds, }); } /** * Mark the given participants as eliminated (finalPosition = 0) and, for fantasy * (non-qualifying) events, announce the teams newly eliminated by this run to the * affected leagues' Discord channels. Returns the number of participants marked. * * "Newly eliminated" = participants with no prior result row, so re-running a * generation step never re-announces the same teams. The announcement is a * best-effort side effect: a failure must not fail the generation action, since * the eliminations themselves are already committed. eventId is deliberately * omitted from the recalc call so the announcement doesn't pull in unrelated * completed matches as "Scored Matches". */ async function markEliminatedAndAnnounce( event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean }, participantIds: string[] ): Promise { const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId); const alreadyHadResult = new Set(existingResults.map((r) => r.participantId)); const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id)); for (const participantId of participantIds) { await setParticipantResult(participantId, event.sportsSeasonId, 0); } // QPs (e.g. tennis/CS2 majors) don't get elimination announcements. if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) { try { await recalculateAffectedLeagues(event.sportsSeasonId, database(), { eventName: event.name ?? undefined, eliminatedParticipantIds: newlyEliminatedIds, }); } catch (err) { logger.error("[Eliminations] Discord announcement failed:", err); } } return participantIds.length; } export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); // Brackets are built/scored only on the major's primary window. A // tournament-linked, non-primary event is a read-only mirror that receives // results via fan-out, so reject every mutating action here. { const ev = await getScoringEventById(params.eventId); if (ev && isReadOnlySibling(ev)) { return { error: "This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.", }; } } if (intent === "create-draw-participant") { const name = (formData.get("name") as string | null)?.trim(); const externalId = (formData.get("externalId") as string | null)?.trim() || null; if (!name) return { error: "Participant name is required" }; try { await createParticipant({ sportsSeasonId: params.id, name, externalId }); return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to create participant" }; } } if (intent === "relink-draw-participant") { const participantId = formData.get("participantId") as string | null; const name = (formData.get("name") as string | null)?.trim(); const externalId = (formData.get("externalId") as string | null)?.trim() || null; if (!participantId || !name) return { error: "Participant and name are required" }; try { await updateParticipant(participantId, { name, externalId }); return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` }; } catch (error) { return { error: error instanceof Error ? error.message : "Failed to update participant" }; } } if (intent === "preview-draw") { const rawInput = (formData.get("externalSourceKey") as string | null)?.trim(); const articleTitle = rawInput ? articleTitleFromInput(rawInput) : ""; // Persist the article so the user can preview, then sync, without re-entering. if (articleTitle) { await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle }); } try { const preview = await previewTennisDraw(params.eventId); return { drawPreview: preview }; } catch (error) { logger.error("[preview-draw] error:", error); return { error: error instanceof Error ? error.message : "Failed to preview draw" }; } } if (intent === "sync-draw") { const rawInput = (formData.get("externalSourceKey") as string | null)?.trim(); // Accept a pasted Wikipedia URL or a plain article title; store the title. const articleTitle = rawInput ? articleTitleFromInput(rawInput) : ""; if (articleTitle) { await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle }); } try { const result = await syncTennisDraw(params.eventId); const reviewNote = result.unmatched.length > 0 ? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.` : ""; return { success: `Synced draw: ${result.matchesWritten} matches written ` + `(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`, drawSyncResult: result, }; } catch (error) { logger.error("[sync-draw] error:", error); return { error: error instanceof Error ? error.message : "Failed to sync draw" }; } } 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 (and announce). const event = await getScoringEventById(params.eventId); if (event) { const allParticipants = await findParticipantsBySportsSeasonId(params.id); const participantsInBracket = new Set(participantIds); const toEliminate = allParticipants .filter((p) => !participantsInBracket.has(p.id)) .map((p) => p.id); const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate); logger.log(`[BracketGeneration] Marked ${eliminatedCount} 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", }; } } 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" }; } // A knockout is "newly decided" only when the match wasn't already complete // (mirrors populateBracketFromDraw's first-completion rule) — a re-score/ // correction of an already-finished match must not re-announce the exit. const setWinnerNewlyEliminated = new Set( newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }]) ); // 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 } } } const db = database(); if (event.isQualifyingEvent) { // Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not // fantasy points. matchIds scopes the Discord notification to just this match. await scoreQualifyingBracket( event, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: [matchId], }, setWinnerNewlyEliminated ); } else { // 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 ?? ""), }); await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db); } return { success: "Winner set successfully" }; } catch (error) { logger.error("Error setting winner:", error); return { error: error instanceof Error ? error.message : "Failed to set winner", }; } } if (intent === "set-round-winners") { const round = formData.get("round"); if (typeof round !== "string" || !round) { return { error: "Round is required" }; } try { // Get all winner assignments from form data const winnerAssignments: Array<{ matchId: string; winnerId: string }> = []; for (const [key, value] of formData.entries()) { if (key.startsWith("winner-") && typeof value === "string") { const matchId = key.replace("winner-", ""); winnerAssignments.push({ matchId, winnerId: value }); } } if (winnerAssignments.length === 0) { return { error: "No winners selected" }; } // Get the event to determine the template const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; // Prefer template-defined isScoring over the DB field (DB defaults to true, // so legacy play-in rows may be incorrectly marked as scoring). const roundIsScoring = new Map( template?.rounds.map((r) => [r.name, r.isScoring]) ?? [] ); // Process each winner assignment let successCount = 0; const errors: string[] = []; const processedMatchIds: string[] = []; // Per-match completion + loser, collected so newlyDecidedLosers() can pick out // the batch's first-time knockouts to fan out to mirror windows (a non-scoring- // round exit earns no QP and is otherwise invisible to the mirror). const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = []; 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. // Qualifying majors derive QP from the whole bracket once after the loop // (processQualifyingBracketEvent), so skip the per-match fantasy scoring here. if (!event.isQualifyingEvent) { await processMatchResult({ round: match.round, winnerId, loserId, isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true), sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, matchId, skipSideEffects: true, loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), }); } successCount++; processedMatchIds.push(matchId); // Only after the match fully succeeded: record its prior completion so // newlyDecidedLosers() announces this loser only if it's a first-time exit // (and never for a match whose write failed above). decidedEntries.push({ wasComplete: match.isComplete, loserId }); } 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(); // Qualifying majors: derive QP from the full bracket once for the batch. if (event.isQualifyingEvent) { await processQualifyingBracketEvent(event.id, db); } // Update probabilities first so recalculateAffectedLeagues reads fresh EVs // when computing projected points. try { await updateProbabilitiesAfterResult(event.sportsSeasonId, true); } catch (error) { logger.error(`Error updating probabilities after batch round winners:`, error); } await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds }); // autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for // qualifying majors, which are scored via processQualifyingBracketEvent above. if (!event.isQualifyingEvent) { await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db); } else { // Shared major primary window: propagate this round to siblings, carrying // the batch's newly-decided knockouts so mirrors announce them too. await fanOutMajorIfPrimary(event, { markComplete: false, newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)), }); } } 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` }; } // Qualifying majors (e.g. CS2): QP is derived directly from the bracket via // processQualifyingBracketEvent — there are no seasonParticipantResults rows to // validate against, and final fantasy placements come from finalizeQualifyingPoints. if (event.isQualifyingEvent) { await scoreQualifyingBracket(event, database()); return { success: `${round} completed and qualifying points updated` }; } // Validate round order: ensure previous rounds are complete const existingResults = await findParticipantResultsBySportsSeasonId( params.id ); if (round === "Finals") { // Finals requires Semifinals to be complete (3rd/4th place results exist) const hasSemifinalResults = existingResults.some( (r) => r.finalPosition === 3 || r.finalPosition === 4 ); if (!hasSemifinalResults) { return { error: "Semifinals must be completed before Finals (no 3rd/4th place results found)", }; } } else if (round === "Semifinals") { // Semifinals requires Quarterfinals to be complete if QF matches exist const hasQuarterfinals = matches.some((m) => m.round === "Quarterfinals"); if (hasQuarterfinals) { const hasQuarterfinalsResults = existingResults.some( (r) => r.finalPosition === 5 || r.finalPosition === 6 || r.finalPosition === 7 || r.finalPosition === 8 ); if (!hasQuarterfinalsResults) { return { error: "Quarterfinals must be completed before Semifinals (no 5th-8th place results found)", }; } } } // Get the sports season to find a fantasy season for scoring rules const sportsSeason = await findSportsSeasonById(params.id); if (!sportsSeason) { return { error: "Sports season not found" }; } // Process the playoff event to calculate placements // TODO Phase 2.7: Refactor processPlayoffEvent to use template system // For now, we still need to set playoffRound for the old scoring logic // even though we removed it from the create event UI const db = database(); await db .update(schema.scoringEvents) .set({ playoffRound: round, updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, params.eventId)); // Get a fantasy season ID for scoring calculation const seasonSports = await findSeasonSportsBySportsSeasonId(params.id); if (seasonSports.length === 0) { return { error: "No fantasy seasons found for this sports season", }; } // Process playoff event to update participant results. // skipRecalculate=true: match winners were already set via set-winner/set-round-winners, // which triggered recalculateAffectedLeagues and Discord per match. No need to re-fire. await processPlayoffEvent(params.eventId, undefined, { skipRecalculate: true }); return { success: `${round} completed and placements calculated successfully`, }; } catch (error) { logger.error("Error completing round:", error); return { error: error instanceof Error ? error.message : "Failed to complete round", }; } } if (intent === "reprocess-bracket") { try { const event = await getScoringEventById(params.eventId); if (!event) return { error: "Event not found" }; const matches = await findPlayoffMatchesByEventId(params.eventId); const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId); // Qualifying majors: this is also the cleanup tool for majors that wrongly banked // fantasy points under the old path. Delete the stale seasonParticipantResults rows // (erasing those points), then rebuild correct QP from the bracket. Qualifying sports // have no legitimate per-major fantasy placements — those come from // finalizeQualifyingPoints across all majors. if (event.isQualifyingEvent) { const db = database(); await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db); await processQualifyingBracketEvent(params.eventId, db); // If the season's QP was already finalized, the delete above wiped the final // placements — recompute them from QP totals so standings aren't left blank. const sportsSeason = await findSportsSeasonById(params.id); if (sportsSeason?.qualifyingPointsFinalized) { await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself } else { // skipDiscord: reprocess is a data-correction tool, not a result announcement. await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true }); } // Re-propagate corrected QP to sibling/mirror windows (data-correction; not // final). Call syncMajorFromPrimaryEvent directly rather than the // swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the // mirrors were re-scored — a silent failure here is exactly how mirrors got // left showing stale QP behind a green "success". const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`; if (event.isPrimary && event.tournamentId) { try { const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false }); // Surface a partial fan-out as an error so it renders as a warning // banner, not a green success the admin might skim past while some // mirror windows are left stale. if (report.windowsFailed > 0) { const reasons = report.failures.map((f) => f.error).join("; "); return { error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`, }; } return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` }; } catch (error) { return { error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`, }; } } return { success: `${baseMessage} No mirror windows to sync.` }; } 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 deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db); // Replay each completed match in bracket order (earlier rounds first). const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; const roundOrder = template ? template.rounds.map((r) => r.name) : []; // Build a round→isScoring map from the template so we use the template as the // source of truth rather than the DB column (which defaults to true and may be // wrong for brackets created before the isScoring column was added). const templateRoundIsScoring = new Map( template?.rounds.map((r) => [r.name, r.isScoring]) ?? [] ); const sortedMatches = completed.toSorted((a, b) => { const ai = roundOrder.indexOf(a.round); const bi = roundOrder.indexOf(b.round); return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); }); for (const match of sortedMatches) { if (!match.winnerId || !match.loserId) continue; const isScoring = templateRoundIsScoring.get(match.round) ?? (match.isScoring ?? true); await processMatchResult( { round: match.round, winnerId: match.winnerId, loserId: match.loserId, isScoring, sportsSeasonId: event.sportsSeasonId, bracketTemplateId: event.bracketTemplateId, eventId: event.id, eventName: event.name ?? undefined, matchId: match.id, skipSideEffects: true, loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""), } ); } // Mark participants NOT in any bracket match as eliminated (finalPosition = 0). // This covers teams that didn't make the playoffs/play-in tournament. const allParticipants = await findParticipantsBySportsSeasonId(params.id); const bracketParticipantIds = new Set(); for (const match of matches) { if (match.participant1Id) bracketParticipantIds.add(match.participant1Id); if (match.participant2Id) bracketParticipantIds.add(match.participant2Id); } let eliminatedCount = 0; for (const participant of allParticipants) { if (!bracketParticipantIds.has(participant.id)) { await setParticipantResult( participant.id, event.sportsSeasonId, 0 ); eliminatedCount++; } } // skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement. await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true }); return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` }; } catch (error) { logger.error("Error reprocessing bracket:", error); return { error: error instanceof Error ? error.message : "Failed to reprocess bracket", }; } } if (intent === "finalize-bracket") { try { // Get the event const event = await getScoringEventById(params.eventId); if (!event) { return { error: "Event not found" }; } // Get all matches const matches = await findPlayoffMatchesByEventId(params.eventId); if (matches.length === 0) { return { error: "No bracket exists for this event" }; } // Verify the event has a valid bracket template configured const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null; if (!template) { return { error: "Bracket template not found" }; } // Verify ALL matches are complete const incompleteMatches = matches.filter((m) => !m.isComplete); if (incompleteMatches.length > 0) { return { error: `Cannot finalize: ${incompleteMatches.length} match(es) still incomplete`, }; } // Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the // standard qualifying finalizer for THIS event. Do not mark the whole sports season // completed or recalc fantasy standings — a season spans multiple majors and final // fantasy placements come from finalizeQualifyingPoints across all of them. if (event.isQualifyingEvent) { const db = database(); // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent) // and recalcs participant QP totals. majorsCompleted is derived on read from // completed qualifying events (see getMajorsCompleted) — marking this event // complete below is what advances it. Season-wide fantasy finalization stays with // finalizeQualifyingPoints across all majors. await processQualifyingEvent(params.eventId, db); await db .update(schema.scoringEvents) .set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() }) .where(eq(schema.scoringEvents.id, params.eventId)); await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: params.eventId, eventName: event.name ?? undefined, }); // Finalize: propagate to siblings AND mark every window complete. await fanOutMajorIfPrimary(event, { markComplete: true }); return { success: "Major bracket finalized — qualifying points awarded." }; } // 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)); await db .update(schema.sportsSeasons) .set({ status: "completed", updatedAt: new Date() }) .where(eq(schema.sportsSeasons.id, params.id)); // 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); } await maybeResolveCompletedBracktForSportsSeason(params.id, 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 // (and announce to leagues for fantasy events). const groupsEvent = await getScoringEventById(params.eventId); if (!groupsEvent) { return { error: "Event not found" }; } const allParticipants = await findParticipantsBySportsSeasonId(params.id); const toEliminate = allParticipants .filter((p) => !uniqueParticipants.has(p.id)) .map((p) => p.id); const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate); 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 (and announce // the group-stage eliminations to leagues for fantasy events). const eliminatedIds = await getEliminatedParticipantIds(params.eventId); await markEliminatedAndAnnounce(event, eliminatedIds); 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" }; }