All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m12s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
🚀 Deploy / 🧪 Test (push) Successful in 3m27s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m25s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 14s
Make a "major" (golf/tennis/CS2) scored once on its canonical tournament and fan out to every linked sports_season window and league. Fan-out & completion (app/services/sync-tournament-results.ts): - syncTournamentResults now marks each synced window's event complete (gated by markComplete), recalculates affected leagues, and counts recalc failures so a stale league can't hide behind a "completed" badge - syncMajorFromPrimaryEvent promotes a primary window's derived results to canonical tournament_results (deleting rows for dropped placements) and fans out to siblings; fanOutMajorIfPrimary guards on the primary - placement removals now propagate (stale rows reset to filler) Primary-event model (scoring_events.is_primary, migration 0122): - getPrimaryEventForTournament / isReadOnlySibling / ensurePrimaryEvent / setPrimaryEvent; event creation auto-seeds a primary for bracket majors; "Make primary" button on the tournament page - per-window event/bracket/cs2 pages are read-only for non-primary linked events (not-participating stays editable) Tennis Grand Slam bracket (tennis_128 template + TEMPLATE_ROUND_CONFIG): - bracket-scored qualifying major via the existing bracket pipeline - simulator conditions in-progress EV on the real bracket (honoring completed matches, walkover for withdrawals), QP derived from config, round structure read from the template; CS2 + tennis share resolveStructureSource Backfill (scripts/backfill-major-linking.ts): one-time idempotent reconcile of existing majors (link orphans, designate primary, promote canonical, sync). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { auth } from "~/lib/auth.server";
|
|
import type { Route } from "./+types/$leagueId.sports-seasons.$sportsSeasonId.events.$eventId";
|
|
import {
|
|
findLeagueById,
|
|
isUserLeagueMember,
|
|
isCommissioner,
|
|
findTeamsBySeasonId,
|
|
getUserDisplayName,
|
|
} from "~/models";
|
|
import { getDraftPicks } from "~/models/draft-pick";
|
|
import { getScoringEventById, getPrimaryEventForTournament, isReadOnlySibling } from "~/models/scoring-event";
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
|
import { getCs2StageResultsForEvent } from "~/models/cs2-major-stage";
|
|
import { findSeasonMatchesByScoringEventId } from "~/models/season-match";
|
|
import { getEventResults } from "~/models/event-result";
|
|
import { getBracketTemplate, getOrderedRoundsFromMatches } from "~/lib/bracket-templates";
|
|
import { findGroupsByEventId } from "~/models/tournament-group";
|
|
import { findMatchesByGroupIds, computeGroupStandings } from "~/models/group-stage-match";
|
|
import type { PlayoffMatch } from "~/models/playoff-match";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, inArray } from "drizzle-orm";
|
|
|
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const { leagueId, sportsSeasonId, eventId } = params;
|
|
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view this page", { status: 401 });
|
|
}
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
if (!league) throw new Response("League not found", { status: 404 });
|
|
|
|
const [isUserCommissioner, isUserMember] = await Promise.all([
|
|
isCommissioner(leagueId, userId),
|
|
isUserLeagueMember(leagueId, userId),
|
|
]);
|
|
if (!isUserCommissioner && !isUserMember) {
|
|
throw new Response("You do not have access to this league", { status: 403 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
const [scoringEvent, sportsSeason] = await Promise.all([
|
|
getScoringEventById(eventId),
|
|
db.query.sportsSeasons.findFirst({
|
|
where: eq(schema.sportsSeasons.id, sportsSeasonId),
|
|
with: { sport: true, seasonSports: { with: { season: true } } },
|
|
}),
|
|
]);
|
|
|
|
if (!scoringEvent) throw new Response("Event not found", { status: 404 });
|
|
if (!sportsSeason) throw new Response("Sports season not found", { status: 404 });
|
|
if (scoringEvent.sportsSeasonId !== sportsSeasonId) {
|
|
throw new Response("Event does not belong to this sports season", { status: 404 });
|
|
}
|
|
|
|
const seasonSport = sportsSeason.seasonSports?.find(
|
|
(ss) => ss.season.leagueId === leagueId
|
|
);
|
|
if (!seasonSport) {
|
|
throw new Response("This sports season is not linked to this league", { status: 404 });
|
|
}
|
|
const season = seasonSport.season;
|
|
|
|
// Build team ownership map
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
const draftPicks = await getDraftPicks(season.id);
|
|
const ownerIds = [...new Set(teams.map((t) => t.ownerId).filter(Boolean))] as string[];
|
|
const ownerUserRows = ownerIds.length > 0
|
|
? await db.query.users.findMany({
|
|
where: inArray(schema.users.id, ownerIds),
|
|
columns: { id: true, username: true, displayName: true },
|
|
})
|
|
: [];
|
|
const ownerMap = new Map(ownerUserRows.map((u) => [u.id, getUserDisplayName(u) ?? "Unknown"]));
|
|
const ownershipMap = new Map<string, { teamName: string; teamId: string; ownerName?: string }>();
|
|
for (const pick of draftPicks) {
|
|
const team = teams.find((t) => t.id === pick.teamId);
|
|
if (team && pick.participantId) {
|
|
ownershipMap.set(pick.participantId, {
|
|
teamName: team.name,
|
|
teamId: team.id,
|
|
ownerName: team.ownerId ? ownerMap.get(team.ownerId) : undefined,
|
|
});
|
|
}
|
|
}
|
|
const teamOwnerships = Array.from(ownershipMap.entries()).map(([participantId, data]) => ({
|
|
participantId,
|
|
...data,
|
|
}));
|
|
|
|
const userTeams = teams.filter((t) => t.ownerId === userId);
|
|
const userTeamIds = new Set(userTeams.map((t) => t.id));
|
|
const userParticipantIds = draftPicks
|
|
.filter((p) => p.teamId && userTeamIds.has(p.teamId) && p.participantId)
|
|
.map((p) => p.participantId as string);
|
|
|
|
const simulatorType = sportsSeason.sport?.simulatorType ?? null;
|
|
|
|
// Shared-major fan-out for DISPLAY: a tournament-linked, non-primary event has
|
|
// no bracket/stage rows of its own — those live on the primary window. Read the
|
|
// structure from the primary, then re-key this league's ownership overlay from
|
|
// its own season_participant ids to the PRIMARY's season_participant ids (the
|
|
// ids the bracket structure uses) via the shared canonical participant. QP/
|
|
// results branches keep using this window's local rows and are untouched.
|
|
let structureEventId = eventId;
|
|
let structureBracketTemplateId = scoringEvent.bracketTemplateId;
|
|
let bracketTeamOwnerships = teamOwnerships;
|
|
let bracketUserParticipantIds = userParticipantIds;
|
|
|
|
if (isReadOnlySibling(scoringEvent) && scoringEvent.tournamentId) {
|
|
const primaryEvent = await getPrimaryEventForTournament(scoringEvent.tournamentId);
|
|
if (primaryEvent && primaryEvent.id !== eventId) {
|
|
structureEventId = primaryEvent.id;
|
|
structureBracketTemplateId = primaryEvent.bracketTemplateId;
|
|
|
|
const [siblingParticipants, primaryParticipants] = await Promise.all([
|
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
|
findParticipantsBySportsSeasonId(primaryEvent.sportsSeasonId),
|
|
]);
|
|
// sibling season_participant id → canonical participant id
|
|
const siblingSpToCanonical = new Map(
|
|
siblingParticipants
|
|
.filter((p) => p.participantId)
|
|
.map((p) => [p.id, p.participantId as string])
|
|
);
|
|
// canonical participant id → primary season_participant id
|
|
const canonicalToPrimarySp = new Map(
|
|
primaryParticipants
|
|
.filter((p) => p.participantId)
|
|
.map((p) => [p.participantId as string, p.id])
|
|
);
|
|
const toPrimarySp = (siblingSpId: string): string | undefined => {
|
|
const canonical = siblingSpToCanonical.get(siblingSpId);
|
|
return canonical ? canonicalToPrimarySp.get(canonical) : undefined;
|
|
};
|
|
|
|
bracketTeamOwnerships = teamOwnerships.flatMap((o) => {
|
|
const primarySpId = toPrimarySp(o.participantId);
|
|
return primarySpId ? [{ ...o, participantId: primarySpId }] : [];
|
|
});
|
|
bracketUserParticipantIds = userParticipantIds.flatMap((id) => {
|
|
const primarySpId = toPrimarySp(id);
|
|
return primarySpId ? [primarySpId] : [];
|
|
});
|
|
}
|
|
}
|
|
|
|
type PlayoffMatchWithRelations = Omit<PlayoffMatch, "createdAt" | "updatedAt"> & {
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
participant1: { id: string; name: string } | null;
|
|
participant2: { id: string; name: string } | null;
|
|
winner: { id: string; name: string } | null;
|
|
loser: { id: string; name: string } | null;
|
|
};
|
|
|
|
type RawGroupMatch = Awaited<ReturnType<typeof findMatchesByGroupIds>> extends Map<string, Array<infer T>> ? T : never;
|
|
type GroupStandingData = {
|
|
groupName: string;
|
|
standings: ReturnType<typeof computeGroupStandings>;
|
|
matches: Array<Omit<RawGroupMatch, "scheduledAt"> & { scheduledAt: string | null }>;
|
|
};
|
|
|
|
// CS2 Major — load Swiss stage data + bracket
|
|
if (simulatorType === "cs2_major_qualifying_points") {
|
|
const [cs2StageResults, seasonMatches, playoffMatchRows] = await Promise.all([
|
|
getCs2StageResultsForEvent(structureEventId),
|
|
findSeasonMatchesByScoringEventId(structureEventId),
|
|
db.query.playoffMatches.findMany({
|
|
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
|
|
with: { participant1: true, participant2: true, winner: true, loser: true },
|
|
}),
|
|
]);
|
|
|
|
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
|
|
...m,
|
|
createdAt: m.createdAt.toISOString(),
|
|
updatedAt: m.updatedAt.toISOString(),
|
|
}));
|
|
const templateId = structureBracketTemplateId ?? undefined;
|
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
|
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
|
|
|
return {
|
|
kind: "cs2" as const,
|
|
league,
|
|
sportsSeason,
|
|
scoringEvent,
|
|
teamOwnerships: bracketTeamOwnerships,
|
|
userParticipantIds: bracketUserParticipantIds,
|
|
cs2StageResults,
|
|
seasonMatches: seasonMatches.map((m) => ({
|
|
...m,
|
|
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
|
|
startedAt: m.startedAt ? m.startedAt.toISOString() : null,
|
|
completedAt: m.completedAt ? m.completedAt.toISOString() : null,
|
|
})),
|
|
playoffMatches,
|
|
playoffRounds,
|
|
bracketTemplateId: templateId ?? null,
|
|
};
|
|
}
|
|
|
|
// Bracket events (tennis/golf bracket majors, soccer/UCL group+bracket)
|
|
if (scoringEvent.eventType === "playoff_game") {
|
|
const playoffMatchRows = await db.query.playoffMatches.findMany({
|
|
where: eq(schema.playoffMatches.scoringEventId, structureEventId),
|
|
with: { participant1: true, participant2: true, winner: true, loser: true },
|
|
});
|
|
const playoffMatches: PlayoffMatchWithRelations[] = playoffMatchRows.map((m) => ({
|
|
...m,
|
|
createdAt: m.createdAt.toISOString(),
|
|
updatedAt: m.updatedAt.toISOString(),
|
|
}));
|
|
const templateId = structureBracketTemplateId ?? undefined;
|
|
const template = templateId ? getBracketTemplate(templateId) : undefined;
|
|
const playoffRounds = getOrderedRoundsFromMatches(playoffMatches, template);
|
|
|
|
let groupStandings: GroupStandingData[] = [];
|
|
if (template?.groupStage) {
|
|
const groups = await findGroupsByEventId(structureEventId);
|
|
const matchesByGroupId = await findMatchesByGroupIds(groups.map((g) => g.id));
|
|
groupStandings = groups.map((group) => {
|
|
const groupMatches = matchesByGroupId.get(group.id) ?? [];
|
|
const members = (group.members ?? [])
|
|
.filter((m): m is typeof m & { participant: NonNullable<typeof m.participant> } => m.participant !== null)
|
|
.map((m) => ({ participantId: m.participant.id, participantName: m.participant.name }));
|
|
return {
|
|
groupName: group.groupName,
|
|
standings: computeGroupStandings(
|
|
members,
|
|
groupMatches.map((m) => ({
|
|
participant1Id: m.participant1Id,
|
|
participant2Id: m.participant2Id,
|
|
participant1Score: m.participant1Score,
|
|
participant2Score: m.participant2Score,
|
|
isComplete: m.isComplete,
|
|
}))
|
|
),
|
|
matches: groupMatches.map((m) => ({
|
|
...m,
|
|
scheduledAt: m.scheduledAt ? m.scheduledAt.toISOString() : null,
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Season participant results for scoring display
|
|
const allResults = await db.query.seasonParticipantResults.findMany({
|
|
where: and(
|
|
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.seasonParticipantResults.finalPosition, 0)
|
|
),
|
|
with: { participant: true },
|
|
});
|
|
const preEliminatedParticipants = allResults
|
|
.filter((r): r is typeof r & { participant: NonNullable<typeof r.participant> } => r.participant !== null)
|
|
.map((r) => ({ id: r.participant.id, name: r.participant.name }));
|
|
|
|
return {
|
|
kind: "bracket" as const,
|
|
league,
|
|
sportsSeason,
|
|
scoringEvent,
|
|
teamOwnerships: bracketTeamOwnerships,
|
|
userParticipantIds: bracketUserParticipantIds,
|
|
playoffMatches,
|
|
playoffRounds,
|
|
bracketTemplateId: templateId ?? null,
|
|
groupStandings,
|
|
preEliminatedParticipants,
|
|
};
|
|
}
|
|
|
|
// QP major tournament / racing / other
|
|
const eventResultRows = await getEventResults(eventId);
|
|
const eventResults = eventResultRows
|
|
.filter((r): r is typeof r & { placement: number } => r.placement !== null && !r.notParticipating)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
placement: r.placement,
|
|
qualifyingPointsAwarded: r.qualifyingPointsAwarded,
|
|
rawScore: r.rawScore,
|
|
seasonParticipantId: r.seasonParticipantId,
|
|
participantName: r.seasonParticipant?.name ?? null,
|
|
}));
|
|
|
|
return {
|
|
kind: "results" as const,
|
|
league,
|
|
sportsSeason,
|
|
scoringEvent,
|
|
teamOwnerships,
|
|
userParticipantIds,
|
|
eventResults,
|
|
};
|
|
}
|