* refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1067 lines
39 KiB
TypeScript
1067 lines
39 KiB
TypeScript
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
|
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
|
import {
|
|
findPlayoffMatchesByEventId,
|
|
generateBracketFromTemplate,
|
|
setMatchWinner,
|
|
advanceWinnerTemplate,
|
|
findPlayoffMatchById,
|
|
assignParticipantsToKnockout,
|
|
doesLoserAdvance,
|
|
} from "~/models/playoff-match";
|
|
import {
|
|
createGame,
|
|
updateGame,
|
|
deleteGame,
|
|
type PlayoffMatchGameStatus,
|
|
} from "~/models/playoff-match-game";
|
|
import {
|
|
upsertMatchOdds,
|
|
deleteOddsForParticipant,
|
|
} from "~/models/playoff-match-odds";
|
|
import {
|
|
processPlayoffEvent,
|
|
processMatchResult,
|
|
recalculateAffectedLeagues,
|
|
recalculateStandings,
|
|
} from "~/models/scoring-calculator";
|
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
|
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
|
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
|
import { createDailySnapshot } from "~/models/standings";
|
|
import {
|
|
createGroupsForEvent,
|
|
addMembersToGroup,
|
|
findGroupsByEventId,
|
|
toggleMemberEliminated,
|
|
getAdvancingParticipantIds,
|
|
getEliminatedParticipantIds,
|
|
} from "~/models/tournament-group";
|
|
import {
|
|
createGroupStageMatches,
|
|
generateRoundRobinPairings,
|
|
updateGroupStageMatchResult,
|
|
updateGroupStageMatchSchedule,
|
|
findMatchesByGroupId,
|
|
} from "~/models/group-stage-match";
|
|
import { logger } from "~/lib/logger";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq } from "drizzle-orm";
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
}
|
|
|
|
const event = await getScoringEventById(params.eventId);
|
|
|
|
if (!event) {
|
|
throw new Response("Event not found", { status: 404 });
|
|
}
|
|
|
|
if (event.eventType !== "playoff_game") {
|
|
throw new Response("This event is not a playoff event", { status: 400 });
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
|
|
|
// Fetch tournament groups with their round-robin matches
|
|
const tournamentGroupsRaw = await findGroupsByEventId(params.eventId);
|
|
const tournamentGroups = await Promise.all(
|
|
tournamentGroupsRaw.map(async (group) => ({
|
|
...group,
|
|
groupMatches: await findMatchesByGroupId(group.id),
|
|
}))
|
|
);
|
|
|
|
// The matches already include participant relations from the model query
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
|
sport: { id: string; name: string; type: string; slug: string };
|
|
},
|
|
event,
|
|
participants,
|
|
matches: matches as Array<typeof matches[0] & {
|
|
participant1: { id: string; name: string } | null;
|
|
participant2: { id: string; name: string } | null;
|
|
winner: { id: string; name: string } | null;
|
|
loser: { id: string; name: string } | null;
|
|
games: Array<{ id: string; gameNumber: number; scheduledAt: Date | null; status: string; winner?: { name: string } | null }>;
|
|
odds: Array<{ id: string; participantId: string; moneylineOdds: number; impliedProbability: string; oddsSource?: string | null; participant: { id: string; name: string } | null }>;
|
|
}>,
|
|
tournamentGroups,
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "generate-bracket") {
|
|
const templateId = formData.get("templateId");
|
|
|
|
if (typeof templateId !== "string" || !templateId) {
|
|
return { error: "Template ID is required" };
|
|
}
|
|
|
|
const template = getBracketTemplate(templateId);
|
|
if (!template) {
|
|
return { error: "Invalid bracket template" };
|
|
}
|
|
|
|
// Parse per-event region config for NCAA-style brackets
|
|
let regionOverride: BracketRegion[] | undefined;
|
|
if (template.regions && template.regions.length > 0) {
|
|
const regions: BracketRegion[] = [];
|
|
for (let r = 0; r < template.regions.length; r++) {
|
|
const name = (formData.get(`regionName${r}`) as string | null)?.trim()
|
|
|| `Region ${r + 1}`;
|
|
const seedsRaw = (formData.get(`regionPlayInSeeds${r}`) as string | null) || "";
|
|
const playInSeeds = seedsRaw
|
|
.split(",")
|
|
.map((s) => parseInt(s.trim(), 10))
|
|
.filter((n) => !isNaN(n) && n >= 1 && n <= 16);
|
|
const playInSet = new Set(playInSeeds);
|
|
regions.push({
|
|
name,
|
|
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
|
|
playIns: playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
|
|
});
|
|
}
|
|
// Validate total = template.totalTeams
|
|
const total = regions.reduce(
|
|
(sum, r) => sum + r.directSeeds.length + r.playIns.length * 2,
|
|
0
|
|
);
|
|
if (total !== template.totalTeams) {
|
|
return {
|
|
error: `Region config accounts for ${total} team slots but template requires ${template.totalTeams}`,
|
|
};
|
|
}
|
|
regionOverride = regions;
|
|
}
|
|
|
|
const participantIds: string[] = [];
|
|
|
|
for (let i = 0; i < template.totalTeams; i++) {
|
|
const participantId = formData.get(`participant${i}`);
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: `Participant ${i + 1} is required` };
|
|
}
|
|
participantIds.push(participantId);
|
|
}
|
|
|
|
// Check for duplicates
|
|
const uniqueParticipants = new Set(participantIds);
|
|
if (uniqueParticipants.size !== participantIds.length) {
|
|
return { error: "Each participant can only be selected once" };
|
|
}
|
|
|
|
try {
|
|
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
|
|
|
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (event) {
|
|
// Get all participants in the sports season
|
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
|
|
// Create a set of participants in the bracket for fast lookup
|
|
const participantsInBracket = new Set(participantIds);
|
|
|
|
// Mark participants NOT in the bracket as eliminated
|
|
for (const participant of allParticipants) {
|
|
if (!participantsInBracket.has(participant.id)) {
|
|
await setParticipantResult(
|
|
participant.id,
|
|
event.sportsSeasonId,
|
|
0 // 0 = didn't make playoffs, eliminated
|
|
);
|
|
}
|
|
}
|
|
|
|
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
|
}
|
|
|
|
// Update the event to store the template ID, scoring start round, and region config
|
|
await updateScoringEvent(params.eventId, {
|
|
bracketTemplateId: templateId,
|
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
|
bracketRegionConfig: regionOverride,
|
|
});
|
|
|
|
return { success: "Bracket generated successfully" };
|
|
} catch (error) {
|
|
logger.error("Error generating bracket:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to generate bracket",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-complete a round when every match in it is marked complete.
|
|
* Updates scoringEvents.playoffRound and calls processPlayoffEvent so that
|
|
* round placements are recorded and probabilities are refreshed.
|
|
* This replaces the manual "Complete Round" button.
|
|
*
|
|
* skipRecalculate=true because the caller already sent a Discord notification
|
|
* scoped to the current batch of matches — we don't want a second one that
|
|
* would show all previously-completed matches in the event.
|
|
*/
|
|
async function autoCompleteRoundIfDone(
|
|
eventId: string,
|
|
round: string,
|
|
sportsSeasonId: string,
|
|
db: ReturnType<typeof database>
|
|
): Promise<void> {
|
|
const allMatches = await findPlayoffMatchesByEventId(eventId);
|
|
const roundMatches = allMatches.filter((m) => m.round === round);
|
|
if (roundMatches.length === 0) return;
|
|
const allDone = roundMatches.every((m) => m.isComplete);
|
|
if (!allDone) return;
|
|
|
|
await db
|
|
.update(schema.scoringEvents)
|
|
.set({ playoffRound: round, updatedAt: new Date() })
|
|
.where(eq(schema.scoringEvents.id, eventId));
|
|
|
|
await processPlayoffEvent(eventId, db, { skipRecalculate: true });
|
|
logger.log(`[AutoComplete] Round "${round}" auto-completed for event ${eventId}`);
|
|
}
|
|
|
|
if (intent === "set-winner") {
|
|
const matchId = formData.get("matchId");
|
|
const winnerId = formData.get("winnerId");
|
|
|
|
if (typeof matchId !== "string" || !matchId) {
|
|
return { error: "Match ID is required" };
|
|
}
|
|
|
|
if (typeof winnerId !== "string" || !winnerId) {
|
|
return { error: "Winner ID is required" };
|
|
}
|
|
|
|
try {
|
|
const match = await findPlayoffMatchById(matchId);
|
|
if (!match) {
|
|
return { error: "Match not found" };
|
|
}
|
|
|
|
// Get the event to determine the template
|
|
const event = await getScoringEventById(match.scoringEventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Determine loser
|
|
const loserId =
|
|
match.participant1Id === winnerId
|
|
? match.participant2Id
|
|
: match.participant1Id;
|
|
|
|
if (!loserId) {
|
|
return { error: "Could not determine loser" };
|
|
}
|
|
|
|
// Set the winner
|
|
await setMatchWinner(matchId, winnerId, loserId);
|
|
|
|
// Try to advance the winner to the next round using template
|
|
const setWinnerTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
|
if (setWinnerTemplate) {
|
|
try {
|
|
await advanceWinnerTemplate(matchId, winnerId, setWinnerTemplate);
|
|
} catch (error) {
|
|
// Only ignore "already filled" errors, re-throw unexpected errors
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.includes("already filled")
|
|
) {
|
|
logger.warn("Match already filled, skipping advancement");
|
|
} else {
|
|
throw error; // Re-throw unexpected errors
|
|
}
|
|
}
|
|
}
|
|
|
|
// Immediately score this match: loser gets their final placement,
|
|
// winner gets provisional floor points (isPartialScore=true).
|
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
|
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
|
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
|
await processMatchResult({
|
|
round: match.round,
|
|
winnerId,
|
|
loserId,
|
|
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
|
sportsSeasonId: event.sportsSeasonId,
|
|
bracketTemplateId: event.bracketTemplateId,
|
|
eventId: event.id,
|
|
eventName: event.name ?? undefined,
|
|
matchId,
|
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
|
});
|
|
|
|
const db = database();
|
|
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
|
|
|
return { success: "Winner set successfully" };
|
|
} catch (error) {
|
|
logger.error("Error setting winner:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to set winner",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "set-round-winners") {
|
|
const round = formData.get("round");
|
|
|
|
if (typeof round !== "string" || !round) {
|
|
return { error: "Round is required" };
|
|
}
|
|
|
|
try {
|
|
// Get all winner assignments from form data
|
|
const winnerAssignments: Array<{ matchId: string; winnerId: string }> = [];
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
if (key.startsWith("winner-") && typeof value === "string") {
|
|
const matchId = key.replace("winner-", "");
|
|
winnerAssignments.push({ matchId, winnerId: value });
|
|
}
|
|
}
|
|
|
|
if (winnerAssignments.length === 0) {
|
|
return { error: "No winners selected" };
|
|
}
|
|
|
|
// Get the event to determine the template
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
|
// Prefer template-defined isScoring over the DB field (DB defaults to true,
|
|
// so legacy play-in rows may be incorrectly marked as scoring).
|
|
const roundIsScoring = new Map<string, boolean>(
|
|
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
|
|
);
|
|
|
|
// Process each winner assignment
|
|
let successCount = 0;
|
|
const errors: string[] = [];
|
|
const processedMatchIds: string[] = [];
|
|
|
|
for (const { matchId, winnerId } of winnerAssignments) {
|
|
try {
|
|
const match = await findPlayoffMatchById(matchId);
|
|
if (!match) {
|
|
errors.push(`Match ${matchId} not found`);
|
|
continue;
|
|
}
|
|
|
|
// Guard: ensure the match actually belongs to the submitted round.
|
|
if (match.round !== round) {
|
|
errors.push(`Match ${match.matchNumber} is in round "${match.round}", not "${round}"`);
|
|
continue;
|
|
}
|
|
|
|
// Determine loser
|
|
const loserId =
|
|
match.participant1Id === winnerId
|
|
? match.participant2Id
|
|
: match.participant1Id;
|
|
|
|
if (!loserId) {
|
|
errors.push(`Could not determine loser for match ${match.matchNumber}`);
|
|
continue;
|
|
}
|
|
|
|
// Set the winner
|
|
await setMatchWinner(matchId, winnerId, loserId);
|
|
|
|
// Try to advance the winner to the next round using template
|
|
if (template) {
|
|
try {
|
|
await advanceWinnerTemplate(matchId, winnerId, template);
|
|
} catch (error) {
|
|
// Only ignore "already filled" errors
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.includes("already filled")
|
|
) {
|
|
logger.warn("Match already filled, skipping advancement");
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Score this match without triggering standings/probability recalc yet —
|
|
// we batch those side effects into a single call after the loop.
|
|
await processMatchResult({
|
|
round: match.round,
|
|
winnerId,
|
|
loserId,
|
|
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
|
sportsSeasonId: event.sportsSeasonId,
|
|
bracketTemplateId: event.bracketTemplateId,
|
|
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();
|
|
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
|
|
try {
|
|
await updateProbabilitiesAfterResult(event.sportsSeasonId, true);
|
|
} catch (error) {
|
|
logger.error(`Error updating probabilities after batch round winners:`, error);
|
|
}
|
|
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
|
}
|
|
|
|
if (errors.length > 0 && successCount === 0) {
|
|
return { error: `Failed to set winners: ${errors.join(", ")}` };
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
return {
|
|
success: `Set ${successCount} winner(s) successfully`,
|
|
error: `Some errors occurred: ${errors.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
return { success: `Successfully set ${successCount} winner(s) for ${round}` };
|
|
} catch (error) {
|
|
logger.error("Error setting round winners:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to set winners",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "complete-round") {
|
|
const round = formData.get("round");
|
|
|
|
if (typeof round !== "string" || !round) {
|
|
return { error: "Round is required" };
|
|
}
|
|
|
|
try {
|
|
// Get the event and update playoffRound to the completed round
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Verify all matches in this round are complete
|
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
|
|
|
// Validate that the round exists in this event's matches
|
|
const roundMatches = matches.filter((m) => m.round === round);
|
|
if (roundMatches.length === 0) {
|
|
return { error: `Round "${round}" not found in this bracket` };
|
|
}
|
|
|
|
const allComplete = roundMatches.every((m) => m.isComplete);
|
|
if (!allComplete) {
|
|
return { error: `Not all matches in ${round} are complete` };
|
|
}
|
|
|
|
// Validate round order: ensure previous rounds are complete
|
|
const existingResults = await findParticipantResultsBySportsSeasonId(
|
|
params.id
|
|
);
|
|
|
|
if (round === "Finals") {
|
|
// Finals requires Semifinals to be complete (3rd/4th place results exist)
|
|
const hasSemifinalResults = existingResults.some(
|
|
(r) => r.finalPosition === 3 || r.finalPosition === 4
|
|
);
|
|
if (!hasSemifinalResults) {
|
|
return {
|
|
error:
|
|
"Semifinals must be completed before Finals (no 3rd/4th place results found)",
|
|
};
|
|
}
|
|
} else if (round === "Semifinals") {
|
|
// Semifinals requires Quarterfinals to be complete if QF matches exist
|
|
const hasQuarterfinals = matches.some((m) => m.round === "Quarterfinals");
|
|
if (hasQuarterfinals) {
|
|
const hasQuarterfinalsResults = existingResults.some(
|
|
(r) =>
|
|
r.finalPosition === 5 ||
|
|
r.finalPosition === 6 ||
|
|
r.finalPosition === 7 ||
|
|
r.finalPosition === 8
|
|
);
|
|
if (!hasQuarterfinalsResults) {
|
|
return {
|
|
error:
|
|
"Quarterfinals must be completed before Semifinals (no 5th-8th place results found)",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get the sports season to find a fantasy season for scoring rules
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
if (!sportsSeason) {
|
|
return { error: "Sports season not found" };
|
|
}
|
|
|
|
// Process the playoff event to calculate placements
|
|
// TODO Phase 2.7: Refactor processPlayoffEvent to use template system
|
|
// For now, we still need to set playoffRound for the old scoring logic
|
|
// even though we removed it from the create event UI
|
|
const db = database();
|
|
await db
|
|
.update(schema.scoringEvents)
|
|
.set({ playoffRound: round, updatedAt: new Date() })
|
|
.where(eq(schema.scoringEvents.id, params.eventId));
|
|
|
|
// Get a fantasy season ID for scoring calculation
|
|
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
|
|
if (seasonSports.length === 0) {
|
|
return {
|
|
error: "No fantasy seasons found for this sports season",
|
|
};
|
|
}
|
|
|
|
// Process playoff event to update participant results.
|
|
// skipRecalculate=true: match winners were already set via set-winner/set-round-winners,
|
|
// which triggered recalculateAffectedLeagues and Discord per match. No need to re-fire.
|
|
await processPlayoffEvent(params.eventId, undefined, { skipRecalculate: true });
|
|
|
|
return {
|
|
success: `${round} completed and placements calculated successfully`,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error completing round:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to complete round",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "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);
|
|
|
|
if (completed.length === 0) {
|
|
return { error: "No completed matches to reprocess" };
|
|
}
|
|
|
|
// Delete ALL results for this sports season and rebuild from scratch.
|
|
// Only deleting partial rows leaves stale finalized rows that block
|
|
// the "never un-finalize" guard in upsertParticipantResult.
|
|
const db = database();
|
|
await db
|
|
.delete(schema.seasonParticipantResults)
|
|
.where(
|
|
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
|
|
);
|
|
|
|
// Replay each completed match in bracket order (earlier rounds first).
|
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
|
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
|
// Build a round→isScoring map from the template so we use the template as the
|
|
// source of truth rather than the DB column (which defaults to true and may be
|
|
// wrong for brackets created before the isScoring column was added).
|
|
const templateRoundIsScoring = new Map<string, boolean>(
|
|
template?.rounds.map((r) => [r.name, r.isScoring]) ?? []
|
|
);
|
|
|
|
const sortedMatches = completed.toSorted((a, b) => {
|
|
const ai = roundOrder.indexOf(a.round);
|
|
const bi = roundOrder.indexOf(b.round);
|
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
});
|
|
|
|
for (const match of sortedMatches) {
|
|
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`,
|
|
};
|
|
}
|
|
|
|
// Get all participants in this sports season
|
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
|
|
// Get participants in matches
|
|
const participantsInMatches = new Set(
|
|
matches.flatMap((m) => [m.participant1Id, m.participant2Id].filter(Boolean))
|
|
);
|
|
|
|
const db = database();
|
|
|
|
// Assign 0 points to participants not in the bracket (Q20)
|
|
for (const participant of allParticipants) {
|
|
if (!participantsInMatches.has(participant.id)) {
|
|
await setParticipantResult(
|
|
participant.id,
|
|
params.id,
|
|
0 // 0 placement = 0 points
|
|
);
|
|
}
|
|
}
|
|
|
|
// Mark event as complete
|
|
await db
|
|
.update(schema.scoringEvents)
|
|
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
|
|
.where(eq(schema.scoringEvents.id, params.eventId));
|
|
|
|
// Recalculate standings for all affected fantasy seasons
|
|
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
|
|
|
|
for (const seasonSport of seasonSports) {
|
|
await recalculateStandings(seasonSport.seasonId, db);
|
|
await createDailySnapshot(seasonSport.seasonId, db);
|
|
}
|
|
|
|
return {
|
|
success: `Bracket finalized! All placements calculated and standings updated.`,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error finalizing bracket:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to finalize bracket",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "generate-groups") {
|
|
const templateId = formData.get("templateId");
|
|
|
|
if (typeof templateId !== "string" || !templateId) {
|
|
return { error: "Template ID is required" };
|
|
}
|
|
|
|
const template = getBracketTemplate(templateId);
|
|
if (!template || !template.groupStage) {
|
|
return { error: "Invalid template or template has no group stage" };
|
|
}
|
|
|
|
const { groupStage } = template;
|
|
const totalTeams = groupStage.groupCount * groupStage.teamsPerGroup;
|
|
|
|
// Collect participant IDs from form
|
|
const participantIds: string[] = [];
|
|
for (let i = 0; i < totalTeams; i++) {
|
|
const participantId = formData.get(`participant${i}`);
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: `Participant ${i + 1} is required` };
|
|
}
|
|
participantIds.push(participantId);
|
|
}
|
|
|
|
// Check for duplicates
|
|
const uniqueParticipants = new Set(participantIds);
|
|
if (uniqueParticipants.size !== participantIds.length) {
|
|
return { error: "Each participant can only be selected once" };
|
|
}
|
|
|
|
try {
|
|
// Update the event with template info
|
|
await updateScoringEvent(params.eventId, {
|
|
bracketTemplateId: templateId,
|
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
|
});
|
|
|
|
// Create tournament groups
|
|
const groups = await createGroupsForEvent(params.eventId, groupStage.groupLabels);
|
|
|
|
// Distribute participants into groups (in selection order) and create round-robin matches
|
|
for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
const startIdx = groupIndex * groupStage.teamsPerGroup;
|
|
const groupParticipantIds = participantIds.slice(
|
|
startIdx,
|
|
startIdx + groupStage.teamsPerGroup
|
|
);
|
|
await addMembersToGroup(groups[groupIndex].id, groupParticipantIds);
|
|
|
|
// Create the 6 round-robin match pairings for this group
|
|
if (groupParticipantIds.length === 4) {
|
|
const pairings = generateRoundRobinPairings(groupParticipantIds);
|
|
await createGroupStageMatches(groups[groupIndex].id, pairings);
|
|
}
|
|
}
|
|
|
|
// Generate the empty knockout bracket structure
|
|
await generateBracketFromTemplate(params.eventId, templateId);
|
|
|
|
// Eliminate participants from this sport season who are not in any group
|
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
let eliminatedCount = 0;
|
|
for (const participant of allParticipants) {
|
|
if (!uniqueParticipants.has(participant.id)) {
|
|
await setParticipantResult(participant.id, params.id, 0);
|
|
eliminatedCount++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error generating groups:", error);
|
|
return {
|
|
error: error instanceof Error ? error.message : "Failed to generate groups",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "toggle-elimination") {
|
|
const memberId = formData.get("memberId");
|
|
|
|
if (typeof memberId !== "string" || !memberId) {
|
|
return { error: "Member ID is required" };
|
|
}
|
|
|
|
try {
|
|
const updated = await toggleMemberEliminated(memberId);
|
|
return {
|
|
success: updated.eliminated
|
|
? "Team marked as eliminated"
|
|
: "Team reinstated",
|
|
};
|
|
} catch (error) {
|
|
logger.error("Error toggling elimination:", error);
|
|
return {
|
|
error: error instanceof Error ? error.message : "Failed to toggle elimination",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "update-group-match") {
|
|
const matchId = formData.get("matchId");
|
|
const p1Score = formData.get("participant1Score");
|
|
const p2Score = formData.get("participant2Score");
|
|
|
|
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
|
if (typeof p1Score !== "string" || typeof p2Score !== "string") {
|
|
return { error: "Both scores are required" };
|
|
}
|
|
|
|
const s1 = parseInt(p1Score, 10);
|
|
const s2 = parseInt(p2Score, 10);
|
|
if (isNaN(s1) || isNaN(s2) || s1 < 0 || s2 < 0) {
|
|
return { error: "Scores must be non-negative integers" };
|
|
}
|
|
|
|
try {
|
|
await updateGroupStageMatchResult(matchId, s1, s2);
|
|
return { success: "Match result saved" };
|
|
} catch (error) {
|
|
logger.error("Error updating group match:", error);
|
|
return { error: error instanceof Error ? error.message : "Failed to update match" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-group-match-schedule") {
|
|
const matchId = formData.get("matchId");
|
|
const scheduledAt = formData.get("scheduledAt");
|
|
|
|
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
|
|
|
try {
|
|
const dateValue = typeof scheduledAt === "string" && scheduledAt
|
|
? new Date(scheduledAt)
|
|
: null;
|
|
await updateGroupStageMatchSchedule(matchId, dateValue);
|
|
return { success: "Match time saved" };
|
|
} catch (error) {
|
|
logger.error("Error updating group match schedule:", error);
|
|
return { error: error instanceof Error ? error.message : "Failed to update schedule" };
|
|
}
|
|
}
|
|
|
|
if (intent === "populate-knockout") {
|
|
try {
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Parse match assignments from form
|
|
const assignments: Array<{
|
|
matchNumber: number;
|
|
slot: "participant1Id" | "participant2Id";
|
|
participantId: string;
|
|
}> = [];
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
const matchPattern = /^match-(\d+)-(participant1Id|participant2Id)$/;
|
|
const match = key.match(matchPattern);
|
|
if (match && typeof value === "string" && value) {
|
|
assignments.push({
|
|
matchNumber: parseInt(match[1], 10),
|
|
slot: match[2] as "participant1Id" | "participant2Id",
|
|
participantId: value,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (assignments.length !== 32) {
|
|
return { error: `Expected 32 assignments but got ${assignments.length}` };
|
|
}
|
|
|
|
// Validate all assignments are unique participants
|
|
const assignedParticipantIds = new Set(assignments.map((a) => a.participantId));
|
|
if (assignedParticipantIds.size !== 32) {
|
|
return { error: "All 32 knockout slots must have unique participants" };
|
|
}
|
|
|
|
// Validate all assigned participants are non-eliminated
|
|
const advancingIds = new Set(await getAdvancingParticipantIds(params.eventId));
|
|
for (const participantId of assignedParticipantIds) {
|
|
if (!advancingIds.has(participantId)) {
|
|
return { error: "All assigned participants must be non-eliminated group members" };
|
|
}
|
|
}
|
|
|
|
// Assign participants to knockout bracket
|
|
await assignParticipantsToKnockout(params.eventId, assignments);
|
|
|
|
// Mark eliminated group participants with finalPosition = 0
|
|
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
|
for (const participantId of eliminatedIds) {
|
|
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
|
}
|
|
|
|
logger.log(
|
|
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
|
);
|
|
|
|
return { success: "Knockout bracket populated successfully" };
|
|
} catch (error) {
|
|
logger.error("Error populating knockout:", error);
|
|
return {
|
|
error: error instanceof Error ? error.message : "Failed to populate knockout",
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Game scheduling ──────────────────────────────────────────────────────
|
|
|
|
if (intent === "add-game") {
|
|
const matchId = formData.get("matchId");
|
|
const gameNumber = formData.get("gameNumber");
|
|
const scheduledAt = formData.get("scheduledAt");
|
|
const notes = formData.get("notes");
|
|
|
|
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
|
if (typeof gameNumber !== "string" || !gameNumber) return { error: "gameNumber is required" };
|
|
|
|
try {
|
|
await createGame({
|
|
playoffMatchId: matchId,
|
|
gameNumber: parseInt(gameNumber, 10),
|
|
scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null,
|
|
notes: notes ? (notes as string) : null,
|
|
});
|
|
return { success: "Game added" };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : "Failed to add game" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-game") {
|
|
const gameId = formData.get("gameId");
|
|
const scheduledAt = formData.get("scheduledAt");
|
|
const status = formData.get("status");
|
|
const participant1Score = formData.get("participant1Score");
|
|
const participant2Score = formData.get("participant2Score");
|
|
const winnerId = formData.get("winnerId");
|
|
const notes = formData.get("notes");
|
|
|
|
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
|
|
|
|
const validStatuses: PlayoffMatchGameStatus[] = ["scheduled", "complete", "postponed"];
|
|
if (status && !validStatuses.includes(status as PlayoffMatchGameStatus)) {
|
|
return { error: `Invalid status: must be one of ${validStatuses.join(", ")}` };
|
|
}
|
|
|
|
try {
|
|
const updated = await updateGame(gameId, {
|
|
...(scheduledAt !== null && { scheduledAt: scheduledAt ? new Date(scheduledAt as string) : null }),
|
|
...(status && { status: status as PlayoffMatchGameStatus }),
|
|
...(participant1Score !== null && { participant1Score: (participant1Score as string) || null }),
|
|
...(participant2Score !== null && { participant2Score: (participant2Score as string) || null }),
|
|
...(winnerId !== null && { winnerId: (winnerId as string) || null }),
|
|
...(notes !== null && { notes: (notes as string) || null }),
|
|
});
|
|
if (!updated) return { error: "Game not found" };
|
|
return { success: "Game updated" };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : "Failed to update game" };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete-game") {
|
|
const gameId = formData.get("gameId");
|
|
if (typeof gameId !== "string" || !gameId) return { error: "gameId is required" };
|
|
|
|
try {
|
|
await deleteGame(gameId);
|
|
return { success: "Game deleted" };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : "Failed to delete game" };
|
|
}
|
|
}
|
|
|
|
// ── Odds management ──────────────────────────────────────────────────────
|
|
|
|
if (intent === "upsert-odds") {
|
|
const matchId = formData.get("matchId");
|
|
const participantId = formData.get("participantId");
|
|
const moneylineOdds = formData.get("moneylineOdds");
|
|
const oddsSource = formData.get("oddsSource");
|
|
|
|
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
|
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
|
|
if (typeof moneylineOdds !== "string" || !moneylineOdds) return { error: "moneylineOdds is required" };
|
|
|
|
const moneylineInt = parseInt(moneylineOdds, 10);
|
|
if (isNaN(moneylineInt)) return { error: "moneylineOdds must be an integer" };
|
|
|
|
try {
|
|
await upsertMatchOdds(matchId, participantId, {
|
|
moneylineOdds: moneylineInt,
|
|
oddsSource: oddsSource ? (oddsSource as string) : undefined,
|
|
});
|
|
return { success: "Odds updated" };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : "Failed to update odds" };
|
|
}
|
|
}
|
|
|
|
if (intent === "delete-odds") {
|
|
const matchId = formData.get("matchId");
|
|
const participantId = formData.get("participantId");
|
|
if (typeof matchId !== "string" || !matchId) return { error: "matchId is required" };
|
|
if (typeof participantId !== "string" || !participantId) return { error: "participantId is required" };
|
|
|
|
try {
|
|
await deleteOddsForParticipant(matchId, participantId);
|
|
return { success: "Odds deleted" };
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : "Failed to delete odds" };
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid action" };
|
|
}
|