brackt/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts
Claude efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon:

1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/
   mirror windows scored via the placement-group path in processQualifyingEvent,
   which took the tie span from the players present on that window's roster
   (a draftable subset) instead of the full field. A window holding fewer than
   the 8 tied R16 losers split the 9-16 points too few ways and over-awarded.
   The tie span is now derived from the canonical tournament_results (the whole
   field) for tournament-linked events, falling back to the live count only for
   standalone events. Every league now scores identically.

2. Discord notifications rounded QP with Math.round, turning 1.5 into "2".
   Both the "Points Awarded" and "QP Standings" values now use a 2-decimal
   formatter mirroring the web UI's formatQP, so Discord and the site agree.

3. The Discord "QP Standings" block ranked players only among that event's
   scorers, showing two R16 losers as T1 instead of T9. Rank is now computed
   over the full season field and passed through to the notifier.

4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored
   counter incremented on every fan-out sync with a guard that misfired. It is
   now derived on read (getMajorsCompleted = count of completed qualifying
   events), which is self-correcting; the increment/decrement writes are removed
   along with the now-unused hasProcessedQualifyingPlacement helper.

Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so
already-corrupted seasons are corrected (2 -> 1.5), and threads a
skipNotifications option through processQualifyingEvent / syncTournamentResults
so the backfill does not re-ping every league.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 20:40:47 +00:00

1279 lines
50 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 } 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 windows (data-correction; not final).
await fanOutMajorIfPrimary(event, { markComplete: false });
return {
success: `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`,
};
}
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" };
}