brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
Claude 0baef5a948
Fix reprocess bracket to score mirror windows like the primary
Reprocessing a tennis (qualifying) major recomputed the primary window's
QP correctly (R16 players = 1.5, the average of the 9th–16th values) but
left mirror/sibling windows showing 2 QP. Two defects:

1. The mirror fan-out ran through fanOutMajorIfPrimary, which swallows
   every error and returns void, so reprocess reported a green "success"
   even when mirrors were never re-scored. The reprocess qualifying path
   now calls syncMajorFromPrimaryEvent directly and folds the SyncReport
   (windows synced / failed) into the response, surfacing failures instead
   of hiding them.

2. Mirror windows split QP by counting canonical tournament_results rows
   at each placement, which only equals the round's structural tie span
   when placements are final. Mid-tournament, players floored at a tier
   make the row-count diverge from the tier size, so mirrors split
   differently than the primary. syncMajorFromPrimaryEvent now derives the
   structural span (R16 = 8, QF = 4, SF = 2, Final = 1) from the primary
   bracket via deriveBracketQualifyingStates and merges it over the
   canonical counts, so every window splits identically to the primary at
   every stage. Golf and CS2 Swiss-exit placements keep their canonical
   count via the merge.

Adds a fan-out test covering an R16-in-progress bracket where only 4 rows
sit at placement 9: the mirror is scored with the structural span (8), not
the live count (4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UkZVYgLmquWV2xDn349T2
2026-07-04 01:45:53 +00:00

1296 lines
51 KiB
TypeScript

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";
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<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,
};
}
/**
* 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<typeof database>,
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2]
): Promise<void> {
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 });
}
/**
* 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<number> {
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" };
}
// 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],
});
} 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<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.
// 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);
} 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.
await fanOutMajorIfPrimary(event, { markComplete: false });
}
}
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 });
let fanout = ` Synced ${report.windowsSynced} mirror window(s).`;
if (report.windowsFailed > 0) {
const reasons = report.failures.map((f) => f.error).join("; ");
fanout = ` Synced ${report.windowsSynced} mirror window(s); ${report.windowsFailed} failed: ${reasons}`;
}
return { success: `${baseMessage}${fanout}` };
} 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<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) {
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<string>();
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" };
}