All three predate the EV fix on this branch and were surfaced by a review
of the full main..HEAD range.
1. reprocess-bracket skipped its wipe exactly when it was needed.
The wipe was guarded on `completed.length > 0`, but clear-bracket
deliberately leaves placements alone and tells the admin to "Run
Reprocess Bracket after rebuilding to clear the placements those
results produced". After clear then regenerate nothing is completed,
so the wipe was skipped and the discarded bracket's finalized
placements survived — and upsertParticipantResult's never-un-finalize
guard then stopped the entry floors and the replay from correcting
them. The advertised recovery path could not work.
The guard was not arbitrary: seasonParticipantResults is keyed by
sports season, not by event, so a season-wide delete takes every
other event's placements with it. Rather than flip the condition,
narrow the delete. New deleteParticipantResultsForParticipants scopes
it to the participants the bracket actually holds, which removes the
collateral damage the guard was defending against, so the delete can
run unconditionally. The participant set was already being computed
further down for the elimination pass; it is now built once and
reused. The qualifying branch keeps its season-wide delete, which is
deliberate and rebuilds via finalizeQualifyingPoints.
2. Banked entry floors could miss teamStandings.totalPoints.
generate-bracket recalculated standings only when `toEliminate` was
empty, assuming markEliminatedAndAnnounce covers every other case. It
does not — it recalculates only when the event is non-qualifying AND
somebody was *newly* eliminated, i.e. had no prior result row. So the
second run of a generation (the first wrote 0 for every non-bracket
participant) recalculated nowhere, and neither did a qualifying event
with teams to eliminate. The floors never reached the standings.
markEliminatedAndAnnounce now returns { markedCount, recalculated }
and the caller drives off that fact instead of re-deriving it, which
also covers the case where the announcement threw — the catch
swallows the error, and a failed recalc is precisely when the
fallback should run.
3. The NBA mobile pager fell back to index geometry.
Its BracketTreePaginated was the only one of five call sites not
forwarding feeders/template, so mobile rendered "TBD" where desktop
rendered "Winner of ...".
Tests: reprocess wipes on a bracket with nothing played, stays scoped to
the bracket, dedupes and skips empty slots, and leaves the qualifying
path alone; generate recalculates in each of the four gaps above and
still does not double-recalculate; and the NBA layout gives its mobile
pane the same slot labels as desktop. Each was confirmed to fail against
the previous behavior.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
1457 lines
59 KiB
TypeScript
1457 lines
59 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,
|
|
deletePlayoffMatchesByEventId,
|
|
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,
|
|
applyBracketEntryFloors,
|
|
} 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,
|
|
deleteParticipantResultsForParticipants,
|
|
} 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<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],
|
|
/**
|
|
* 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<string>
|
|
): Promise<void> {
|
|
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
|
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
|
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
|
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
|
// this (primary) window via skipEventId, so mirror windows are announced separately
|
|
// with no double-post.
|
|
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
|
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.
|
|
*
|
|
* "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".
|
|
*
|
|
* Returns the number of participants marked alongside whether a standings recalculation
|
|
* actually ran. The caller banks entry floors before calling this and needs them to
|
|
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
|
* because the recalc is skipped for qualifying events, when every eliminated team
|
|
* already had a result row (the second run of a generation), and when the announcement
|
|
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
|
*/
|
|
async function markEliminatedAndAnnounce(
|
|
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
|
participantIds: string[]
|
|
): Promise<{ markedCount: number; recalculated: boolean }> {
|
|
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);
|
|
}
|
|
|
|
let recalculated = false;
|
|
|
|
// 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,
|
|
});
|
|
recalculated = true;
|
|
} catch (err) {
|
|
logger.error("[Eliminations] Discord announcement failed:", err);
|
|
}
|
|
}
|
|
|
|
return { markedCount: participantIds.length, recalculated };
|
|
}
|
|
|
|
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" };
|
|
}
|
|
}
|
|
|
|
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
|
|
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
|
|
if (intent === "clear-bracket") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) return { error: "Event not found" };
|
|
|
|
const existing = await findPlayoffMatchesByEventId(params.eventId);
|
|
if (existing.length === 0) {
|
|
return { error: "This event has no bracket to clear" };
|
|
}
|
|
// Clearing discards recorded results, so make the admin confirm once games have
|
|
// actually been played.
|
|
const completed = existing.filter((m) => m.isComplete).length;
|
|
if (completed > 0 && formData.get("confirm") !== "true") {
|
|
return {
|
|
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
|
|
};
|
|
}
|
|
|
|
await deletePlayoffMatchesByEventId(params.eventId);
|
|
|
|
// Placements are deliberately left alone. seasonParticipantResults is keyed by
|
|
// sports season, not by event, so a season-wide delete here would wipe the
|
|
// placements of every other event in the season with nothing to rebuild them —
|
|
// and on a finalized qualifying season that means permanently zeroed standings.
|
|
// Reprocess Bracket already rebuilds placements correctly, qualifying path
|
|
// included, so point the admin at it once the new bracket is in place.
|
|
const note =
|
|
completed > 0
|
|
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
|
|
: "";
|
|
|
|
return {
|
|
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error clearing bracket:", error);
|
|
return {
|
|
error: error instanceof Error ? error.message : "Failed to clear bracket",
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// The template ID has to land on the event before entry floors can be derived
|
|
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
|
// elimination pass below.
|
|
await updateScoringEvent(params.eventId, {
|
|
bracketTemplateId: templateId,
|
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
|
bracketRegionConfig: regionOverride,
|
|
});
|
|
|
|
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
|
|
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
|
|
// those provisional floors now, ahead of the elimination announcement below so
|
|
// the standings it posts already reflect them.
|
|
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
|
|
if (entryFloorCount > 0) {
|
|
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
|
|
}
|
|
|
|
// 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 { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
|
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
|
|
|
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
|
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
|
// others: not for a qualifying event, not when every eliminated team already had
|
|
// a result row (the second run of a generation, since the first wrote 0 for all
|
|
// of them), not when there was nobody to eliminate, and not when the announcement
|
|
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
|
// skipDiscord: seeding floors are not a result to announce.
|
|
if (entryFloorCount > 0 && !recalculated) {
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
|
eventName: event.name ?? undefined,
|
|
skipDiscord: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
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<string, boolean>(
|
|
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, and
|
|
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
|
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
|
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
|
if (event.isQualifyingEvent) {
|
|
await processQualifyingEvent(event.id, db, {
|
|
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
|
});
|
|
}
|
|
// 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 (matches.length === 0) {
|
|
return { error: "No bracket to reprocess" };
|
|
}
|
|
|
|
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
|
// only the partial rows would leave stale finalized ones, which the "never
|
|
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
|
//
|
|
// Scoped to the participants this bracket actually holds, not the whole season:
|
|
// seasonParticipantResults is keyed by sports season, not by event, so a
|
|
// season-wide delete takes every other event's placements with it and only this
|
|
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
|
//
|
|
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
|
// regenerate → reprocess repair path: the discarded bracket's finalized
|
|
// placements are exactly what needs clearing, and there is always something to
|
|
// rebuild from — the entry floors below, then the replay.
|
|
const db = database();
|
|
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
|
const bracketParticipantIds = new Set<string>();
|
|
for (const match of matches) {
|
|
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
|
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
|
}
|
|
await deleteParticipantResultsForParticipants(
|
|
event.sportsSeasonId,
|
|
[...bracketParticipantIds],
|
|
db
|
|
);
|
|
|
|
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
|
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
|
// them; a bracket with no completed matches still gets its guaranteed points.
|
|
const entryFloorCount = await applyBracketEntryFloors(params.eventId, 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);
|
|
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, ` +
|
|
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
|
`${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 { markedCount: 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" };
|
|
}
|