brackt/app/models/scoring-event.ts
Claude ec89e3eb89
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m26s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Address review: don't auto-promote primary for golf; lighten window count
- Auto-heal now only promotes a new primary when the deleted event was
  itself the primary window. Golf-style shared majors intentionally have
  no primary (scored on the canonical tournament page), so deleting one
  of their windows no longer flips a sibling into a primary and changes
  its scoring/guard behavior. Adds a regression test for that case.
- Replace the relation-heavy getSportsSeasonsByTournament + double
  Array.find in the events loader with a new countWindowsByTournament
  helper (count(distinct sports_season_id)) and a single cached lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
2026-07-02 05:27:14 +00:00

1238 lines
44 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
import type { BracketRegion } from "~/lib/bracket-templates";
import { recalculateAffectedLeagues } from "./scoring-calculator";
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
import { findParticipantNamesByIds } from "./season-participant";
import { deleteTournament } from "./tournament";
import type { EventType } from "./scoring-event-types";
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
export interface CreateScoringEventData {
sportsSeasonId: string;
name: string;
eventDate?: Date;
eventStartsAt?: Date;
eventType: EventType;
isQualifyingEvent?: boolean;
/** Required when isQualifyingEvent=true (DB check constraint). */
tournamentId?: string;
// Template system fields (Phase 2.6)
bracketTemplateId?: string;
scoringStartsAtRound?: string;
}
export interface UpdateScoringEventData {
name?: string;
eventDate?: Date | null;
eventStartsAt?: Date | null;
isComplete?: boolean;
completedAt?: Date;
bracketTemplateId?: string;
scoringStartsAtRound?: string;
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
bracketRegionConfig?: BracketRegion[] | null;
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
externalSourceKey?: string | null;
}
/**
* Create a new scoring event
*/
export async function createScoringEvent(
data: CreateScoringEventData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const [event] = await db
.insert(schema.scoringEvents)
.values({
sportsSeasonId: data.sportsSeasonId,
name: data.name,
eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined,
eventStartsAt: data.eventStartsAt,
eventType: data.eventType,
isQualifyingEvent: data.isQualifyingEvent || false,
tournamentId: data.tournamentId,
bracketTemplateId: data.bracketTemplateId,
scoringStartsAtRound: data.scoringStartsAtRound,
isComplete: false,
})
.returning();
return event;
}
/**
* Get a scoring event by ID
*/
export async function getScoringEventById(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
/**
* Get all scoring events for a sports season
*/
export async function getScoringEventsForSportsSeason(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.eventStartsAt), desc(schema.scoringEvents.createdAt)],
with: {
eventResults: {
with: {
seasonParticipant: true,
},
},
},
});
}
/**
* Get incomplete scoring events for a sports season
*/
export async function getIncompleteScoringEvents(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false)
),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
});
}
/**
* Get qualifying events for a sports season
*/
export async function getQualifyingEvents(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isQualifyingEvent, true)
),
orderBy: [desc(schema.scoringEvents.eventDate), desc(schema.scoringEvents.createdAt)],
});
}
/**
* Update a scoring event
*/
export async function updateScoringEvent(
eventId: string,
data: UpdateScoringEventData,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const updateData: Partial<typeof schema.scoringEvents.$inferInsert> = { updatedAt: new Date() };
if (data.name !== undefined) updateData.name = data.name;
if (data.eventDate !== undefined) {
updateData.eventDate = data.eventDate ? data.eventDate.toISOString().split('T')[0] : null;
}
if (data.eventStartsAt !== undefined) updateData.eventStartsAt = data.eventStartsAt;
if (data.isComplete !== undefined) updateData.isComplete = data.isComplete;
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
const [updated] = await db
.update(schema.scoringEvents)
.set(updateData)
.where(eq(schema.scoringEvents.id, eventId))
.returning();
return updated;
}
/**
* Mark a scoring event as complete
*/
export async function completeScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return await updateScoringEvent(
eventId,
{
isComplete: true,
completedAt: new Date(),
},
db
);
}
/**
* Delete a scoring event.
* For bracket events (playoff_game), also clears all participant results for the
* sports season (placements are stored there with no FK back to the event) and
* recalculates league standings.
*/
export interface DeleteScoringEventOptions {
/**
* When this was the last window linked to its shared tournament, also delete
* the now-orphaned canonical tournament (and its results). No-op when other
* windows remain. Off by default so deleting one season's event never touches
* the shared tournament unless explicitly requested.
*/
deleteOrphanTournament?: boolean;
}
export interface DeleteScoringEventResult {
/** The shared tournament this event was linked to, if any. */
tournamentId: string | null;
/** How many other windows still link to that tournament after the delete. */
remainingWindows: number;
/** Event promoted to primary because the deleted event was the primary window. */
promotedPrimaryId: string | null;
/** Whether the orphaned canonical tournament was deleted. */
deletedTournament: boolean;
}
export async function deleteScoringEvent(
eventId: string,
providedDb?: ReturnType<typeof database>,
opts?: DeleteScoringEventOptions
): Promise<DeleteScoringEventResult> {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event) {
return {
tournamentId: null,
remainingWindows: 0,
promotedPrimaryId: null,
deletedTournament: false,
};
}
// For qualifying events: capture affected participant IDs before the cascade deletes
// eventResults (which is how we know who had QP awarded from this event).
let affectedParticipantIds: string[] = [];
let wasQPProcessed = false;
if (event.isQualifyingEvent) {
const results = await db.query.eventResults.findMany({
where: eq(schema.eventResults.scoringEventId, eventId),
});
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
wasQPProcessed = hasProcessedQualifyingPlacement(results);
}
await db.transaction(async (tx) => {
if (event.eventType === "playoff_game" || event.eventType === "major_tournament") {
await tx
.delete(schema.seasonParticipantResults)
.where(eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId));
}
// Cascades: playoffMatches, eventResults, tournamentGroups/Members
await tx.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId));
});
if (event.eventType === "playoff_game" || event.eventType === "major_tournament") {
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
if (event.isQualifyingEvent && affectedParticipantIds.length > 0) {
// eventResults have been cascade-deleted; recalculate QP totals from remaining events
for (const participantId of affectedParticipantIds) {
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
}
// Decrement majorsCompleted if this event had already been processed
if (wasQPProcessed) {
const sportsSeason = await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
});
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
await db
.update(schema.sportsSeasons)
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
}
}
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
// Shared-tournament bookkeeping: keep the primary window valid and optionally
// clean up a canonical tournament left with no windows.
let remainingWindows = 0;
let promotedPrimaryId: string | null = null;
let deletedTournament = false;
if (event.tournamentId) {
const remaining = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
remainingWindows = remaining.length;
if (remaining.length > 0) {
// Auto-heal: if the deleted event was the primary window, promote the
// earliest remaining window so the shared major stays scorable and
// fan-out stays deterministic. Only heal when we actually removed the
// primary — golf-style majors intentionally have no primary (scored on the
// canonical tournament page), and event.isPrimary is false for them, so
// they are left untouched.
if (event.isPrimary) {
await setPrimaryEvent(remaining[0].id, db);
promotedPrimaryId = remaining[0].id;
}
} else if (opts?.deleteOrphanTournament) {
await deleteTournament(event.tournamentId, db);
deletedTournament = true;
}
}
return {
tournamentId: event.tournamentId,
remainingWindows,
promotedPrimaryId,
deletedTournament,
};
}
/**
* Check if all events for a sports season are complete
*/
export async function areAllEventsComplete(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<boolean> {
const db = providedDb || database();
const incompleteEvents = await getIncompleteScoringEvents(sportsSeasonId, db);
return incompleteEvents.length === 0;
}
/**
* Get upcoming (not yet complete) scoring events for a sports season,
* ordered by effective start date ascending (soonest first).
*
* Effective date = min(eventDate, eventStartsAt, earliest future bracket-game scheduledAt).
* This ensures CS2 Champions Stage events (which have game times set on
* playoff_match_games but no eventDate on the event record) sort correctly.
*/
export async function getUpcomingScoringEvents(
sportsSeasonId: string,
limit = 5,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Over-fetch relative to the requested limit so the subsequent game-time
// re-sort has enough candidates without an unbounded query.
const events = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false)
),
orderBy: [asc(schema.scoringEvents.eventDate), asc(schema.scoringEvents.eventStartsAt), asc(schema.scoringEvents.createdAt)],
limit: Math.max(limit * 5, 50),
});
if (events.length === 0) return [];
// Find earliest upcoming game time per event from both bracket match games
// and group stage matches (FIFA World Cup style).
const eventIds = events.map((e) => e.id);
const now = new Date();
const [gameTimeRows, groupStageTimeRows] = await Promise.all([
db
.select({
eventId: schema.scoringEvents.id,
scheduledAt: schema.playoffMatchGames.scheduledAt,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.innerJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(
and(
inArray(schema.scoringEvents.id, eventIds),
isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, now)
)
),
db
.select({
eventId: schema.tournamentGroups.scoringEventId,
scheduledAt: schema.groupStageMatches.scheduledAt,
})
.from(schema.groupStageMatches)
.innerJoin(
schema.tournamentGroups,
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
)
.where(
and(
inArray(schema.tournamentGroups.scoringEventId, eventIds),
isNotNull(schema.groupStageMatches.scheduledAt),
gte(schema.groupStageMatches.scheduledAt, now)
)
),
]);
const earliestGameById = new Map<string, Date>();
for (const row of [...gameTimeRows, ...groupStageTimeRows]) {
if (!row.scheduledAt) continue;
const prev = earliestGameById.get(row.eventId);
if (!prev || row.scheduledAt < prev) {
earliestGameById.set(row.eventId, row.scheduledAt);
}
}
const getEffectiveMs = (e: (typeof events)[0]): number => {
const candidates: number[] = [];
if (e.eventDate) candidates.push(new Date(e.eventDate + "T00:00:00Z").getTime());
if (e.eventStartsAt) candidates.push(e.eventStartsAt.getTime());
const game = earliestGameById.get(e.id);
if (game) candidates.push(game.getTime());
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
};
return events
.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b))
.slice(0, limit)
.map((e) => ({
...e,
earliestGameTime: earliestGameById.get(e.id)?.toISOString()
?? e.eventStartsAt?.toISOString()
?? null,
}));
}
/**
* Get the single next upcoming event for a sports season (for card display).
*/
export async function getNextScoringEvent(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>
) {
const upcoming = await getUpcomingScoringEvents(sportsSeasonId, 1, providedDb);
return upcoming[0] || null;
}
/**
* Get recently completed scoring events for a sports season,
* ordered by completedAt descending (most recent first).
*/
export async function getRecentCompletedEvents(
sportsSeasonId: string,
limit = 3,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
return db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, true)
),
orderBy: [desc(schema.scoringEvents.completedAt), desc(schema.scoringEvents.eventDate)],
limit,
});
}
// Scoring patterns where each participant competes in every event.
// Valid scoringPatternEnum values: playoff_bracket | season_standings | qualifying_points
const ALL_COMPETE_PATTERNS = new Set(["season_standings", "qualifying_points"]);
export interface UpcomingParticipantEvent {
id: string;
/**
* The actual scoring event database ID. For all-compete events this equals id;
* for bracket events id is a composite key (eventId|matchId|gameNum) so this
* separate field is needed to build event-detail page links.
* null/undefined for group-stage match entries and legacy callers.
*/
scoringEventId?: string | null;
name: string;
eventDate: string | null;
/**
* Earliest scheduledAt timestamp (ISO string) across individual playoff match
* games for this event — bracket sports only. Used when eventDate is null.
*/
earliestGameTime: string | null;
/**
* e.g. "Team Spirit vs G2 Esports" — the bracket matchup, derived from
* playoffMatches.participant1Id/participant2Id (or group-stage match
* participants). Falls back to a round/game-number label (e.g. "Semifinals
* Game #1") when neither participant's name resolves. null when unavailable
* or redundant with event name.
*/
matchLabel: string | null;
eventType: string;
sportsSeasonId: string;
/** simulatorType from the parent sport — used to distinguish bracket-majors from manual-entry majors */
simulatorType?: string | null;
/** Only the current user's drafted participants involved in this event */
relevantParticipants: Array<{ id: string; name: string }>;
}
/**
* Get upcoming scoring events (within the given date window) that involve
* at least one of the given drafted participants.
*
* - Bracket sports (playoff_bracket, ucl_bracket): only events where a
* participant appears in a playoff match.
* - All-compete sports (season_standings, qualifying_points, etc.): every
* upcoming event; all draftedParticipants are attached.
*
* Events without a date or that are already complete are excluded.
*/
export async function getUpcomingEventsForDraftedParticipants(
sportsSeasonId: string,
scoringPattern: string,
draftedParticipants: Array<{ id: string; name: string }>,
dateFromStr: string,
dateToStr: string,
providedDb?: ReturnType<typeof database>
): Promise<UpcomingParticipantEvent[]> {
if (draftedParticipants.length === 0) return [];
const db = providedDb || database();
const draftedIds = draftedParticipants.map((p) => p.id);
const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p]));
// Timestamp bounds used by both all-compete and bracket paths below.
const dateFromTimestampAllCompete = new Date(dateFromStr + "T00:00:00.000Z");
const dateToTimestampAllCompete = new Date(dateToStr + "T23:59:59.999Z");
if (ALL_COMPETE_PATTERNS.has(scoringPattern)) {
// Primary: events with eventDate in the window.
const eventsByDate = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false),
isNotNull(schema.scoringEvents.eventDate),
gte(schema.scoringEvents.eventDate, dateFromStr),
lte(schema.scoringEvents.eventDate, dateToStr)
),
orderBy: [asc(schema.scoringEvents.eventDate)],
});
const foundByDateIds = new Set(eventsByDate.map((e) => e.id));
// Secondary: events that have bracket game times within the window.
// This catches CS2 Champions Stage bracket events whose eventDate is in
// the past (or unset) but which have upcoming playoff_match_games scheduled.
const gameTimeRows = await db
.select({
eventId: schema.scoringEvents.id,
scheduledAt: schema.playoffMatchGames.scheduledAt,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.innerJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false),
isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestampAllCompete),
lte(schema.playoffMatchGames.scheduledAt, dateToTimestampAllCompete)
)
);
// Collect earliest game time per event; track which event IDs are new.
const earliestGameTimeById = new Map<string, Date>();
const extraEventIds = new Set<string>();
for (const row of gameTimeRows) {
if (!row.scheduledAt) continue;
const prev = earliestGameTimeById.get(row.eventId);
if (!prev || row.scheduledAt < prev) {
earliestGameTimeById.set(row.eventId, row.scheduledAt);
}
if (!foundByDateIds.has(row.eventId)) {
extraEventIds.add(row.eventId);
}
}
// Fetch full event records for any extras not already in eventsByDate.
let extraEvents: (typeof eventsByDate)[0][] = [];
if (extraEventIds.size > 0) {
extraEvents = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.id, [...extraEventIds]),
});
}
// Merge and sort by effective start date (eventDate → eventStartsAt → game time).
const allEvents = [...eventsByDate, ...extraEvents];
const getEffectiveMs = (e: (typeof allEvents)[0]): number => {
const candidates: number[] = [];
if (e.eventDate) candidates.push(new Date(e.eventDate + "T00:00:00Z").getTime());
if (e.eventStartsAt) candidates.push(e.eventStartsAt.getTime());
const game = earliestGameTimeById.get(e.id);
if (game) candidates.push(game.getTime());
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
};
allEvents.sort((a, b) => getEffectiveMs(a) - getEffectiveMs(b));
return allEvents.map((e) => {
const gameTime = earliestGameTimeById.get(e.id);
return {
id: e.id,
scoringEventId: e.id,
name: e.name,
eventDate: e.eventDate,
earliestGameTime: gameTime
? gameTime.toISOString()
: e.eventStartsAt
? e.eventStartsAt.toISOString()
: null,
matchLabel: null,
eventType: e.eventType,
sportsSeasonId: e.sportsSeasonId,
relevantParticipants: draftedParticipants,
};
});
}
// Bracket sports: join through playoff_matches → playoff_match_games to find relevant events.
// scoringEvent.eventDate may be null — admins often set dates on individual games
// (playoffMatchGames.scheduledAt) rather than on the event record itself.
// We include an event if EITHER its eventDate OR any of its game scheduledAt timestamps
// falls within the window.
const dateFromTimestamp = dateFromTimestampAllCompete;
const dateToTimestamp = dateToTimestampAllCompete;
const rows = await db
.selectDistinct({
id: schema.scoringEvents.id,
name: schema.scoringEvents.name,
eventDate: schema.scoringEvents.eventDate,
eventType: schema.scoringEvents.eventType,
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
playoffMatchId: schema.playoffMatches.id,
participant1Id: schema.playoffMatches.participant1Id,
participant2Id: schema.playoffMatches.participant2Id,
round: schema.playoffMatches.round,
gameNumber: schema.playoffMatchGames.gameNumber,
scheduledAt: schema.playoffMatchGames.scheduledAt,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.leftJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.isComplete, false),
eq(schema.playoffMatches.isComplete, false),
or(
inArray(schema.playoffMatches.participant1Id, draftedIds),
// participant2Id is nullable UUID — same underlying type as participant1Id;
// the notNull difference only exists at the TypeScript layer
// oxlint-disable-next-line typescript/no-explicit-any -- participant2Id is nullable UUID; notNull difference only exists at the TypeScript layer
// eslint-disable-next-line @typescript-eslint/no-explicit-any
inArray(schema.playoffMatches.participant2Id as any, draftedIds)
),
or(
// scoringEvent has an explicit date within the window
and(
isNotNull(schema.scoringEvents.eventDate),
gte(schema.scoringEvents.eventDate, dateFromStr),
lte(schema.scoringEvents.eventDate, dateToStr)
),
// Or one of the individual games is scheduled within the window
and(
isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
)
)
)
)
.orderBy(asc(schema.scoringEvents.eventDate));
// Look up names for ALL participants in these matches (not just drafted
// ones) so matchLabel can show the "P1 vs P2" matchup, mirroring the
// group-stage matchLabel below. Run alongside the max-game-number lookup
// below since neither depends on the other's result.
const allMatchParticipantIds = [
...new Set(
rows.flatMap((r) => [r.participant1Id, r.participant2Id]).filter((id): id is string => !!id)
),
];
const eventIdsFromRows = [...new Set(rows.map((r) => r.id))];
const [matchParticipantNameById, maxGameNumberByEvent] = await Promise.all([
findParticipantNamesByIds(db, allMatchParticipantIds),
(async () => {
// Determine max game number per event across ALL games (not date-filtered)
// so we can distinguish single-game matchups from multi-game series.
const map = new Map<string, number>();
if (eventIdsFromRows.length === 0) return map;
const allGameRows = await db
.selectDistinct({
eventId: schema.scoringEvents.id,
gameNumber: schema.playoffMatchGames.gameNumber,
})
.from(schema.scoringEvents)
.innerJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.innerJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(inArray(schema.scoringEvents.id, eventIdsFromRows));
for (const row of allGameRows) {
if (row.gameNumber !== null) {
const current = map.get(row.eventId) ?? 0;
map.set(row.eventId, Math.max(current, row.gameNumber));
}
}
return map;
})(),
]);
// Group rows into calendar entries.
// Series events (max game number > 1) get one entry per game so each game
// appears as its own row on the calendar. Single-game events get one entry.
const entryMap = new Map<string, UpcomingParticipantEvent>();
const participantsByEntry = new Map<string, Map<string, { id: string; name: string }>>();
const gameKeysByEntry = new Map<string, Set<string>>();
const earliestTimeByEntry = new Map<string, Date>();
const isSeriesByEntry = new Map<string, boolean>();
const matchupIdsByEntry = new Map<string, { p1Id: string | null; p2Id: string | null }>();
for (const row of rows) {
const isSeries = (maxGameNumberByEvent.get(row.id) ?? 1) > 1;
const entryKey =
isSeries && row.gameNumber !== null
? `${row.id}|${row.playoffMatchId}|${row.gameNumber}`
: `${row.id}|${row.playoffMatchId}`;
if (!entryMap.has(entryKey)) {
entryMap.set(entryKey, {
id: entryKey,
scoringEventId: row.id,
name: row.name,
eventDate: row.eventDate,
earliestGameTime: null,
matchLabel: null,
eventType: row.eventType,
sportsSeasonId: row.sportsSeasonId,
relevantParticipants: [],
});
participantsByEntry.set(entryKey, new Map());
gameKeysByEntry.set(entryKey, new Set());
isSeriesByEntry.set(entryKey, isSeries);
matchupIdsByEntry.set(entryKey, { p1Id: row.participant1Id, p2Id: row.participant2Id });
}
const pMap = participantsByEntry.get(entryKey);
if (pMap) {
if (row.participant1Id) {
const p1 = draftedMap.get(row.participant1Id);
if (p1) pMap.set(row.participant1Id, p1);
}
if (row.participant2Id) {
const p2 = draftedMap.get(row.participant2Id);
if (p2) pMap.set(row.participant2Id, p2);
}
}
if (row.round && row.gameNumber !== null) {
gameKeysByEntry.get(entryKey)?.add(`${row.round}|${row.gameNumber}`);
}
if (row.scheduledAt) {
const prev = earliestTimeByEntry.get(entryKey);
if (!prev || row.scheduledAt < prev) {
earliestTimeByEntry.set(entryKey, row.scheduledAt);
}
}
}
for (const [entryKey, event] of entryMap) {
event.relevantParticipants = Array.from(participantsByEntry.get(entryKey)?.values() ?? []);
const earliest = earliestTimeByEntry.get(entryKey);
event.earliestGameTime = earliest ? earliest.toISOString() : null;
const isSeries = isSeriesByEntry.get(entryKey) ?? false;
const gameKeys = gameKeysByEntry.get(entryKey) ?? new Set<string>();
// matchLabel shows the "P1 vs P2" matchup whenever either participant's
// name is known — the round/game-number info is intentionally NOT
// included (the event name + sport already give that context); it's
// only used as a last-resort fallback when no participant name resolved
// at all (e.g. a deleted/orphaned participant row).
const matchupIds = matchupIdsByEntry.get(entryKey);
const p1Name = matchupIds?.p1Id ? matchParticipantNameById.get(matchupIds.p1Id) : undefined;
const p2Name = matchupIds?.p2Id ? matchParticipantNameById.get(matchupIds.p2Id) : undefined;
const matchupLabel = p1Name || p2Name ? `${p1Name ?? "?"} vs ${p2Name ?? "?"}` : null;
let roundLabel: string | null = null;
if (isSeries) {
if (gameKeys.size >= 1) {
const [round, gameNumberStr] = [...gameKeys][0].split("|");
roundLabel =
round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`;
}
} else {
// Single-game: omit the game number, just show the round name.
if (gameKeys.size === 1) {
const [round] = [...gameKeys][0].split("|");
roundLabel = round === event.name ? null : round;
} else if (gameKeys.size > 1) {
const rounds = new Set([...gameKeys].map((k) => k.split("|")[0]));
if (rounds.size === 1) {
const round = [...rounds][0];
roundLabel = round === event.name ? null : round;
}
}
}
event.matchLabel = matchupLabel ?? roundLabel;
}
const bracketResults = Array.from(entryMap.values());
// Also include group stage matches for the drafted participants.
// Group stage matches live in groupStageMatches, not playoffMatches, so
// the bracket query above misses them entirely.
const groupStageRows = await db
.select({
matchId: schema.groupStageMatches.id,
groupName: schema.tournamentGroups.groupName,
scoringEventName: schema.scoringEvents.name,
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
scheduledAt: schema.groupStageMatches.scheduledAt,
participant1Id: schema.groupStageMatches.participant1Id,
participant2Id: schema.groupStageMatches.participant2Id,
})
.from(schema.groupStageMatches)
.innerJoin(
schema.tournamentGroups,
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
)
.innerJoin(
schema.scoringEvents,
eq(schema.tournamentGroups.scoringEventId, schema.scoringEvents.id)
)
.where(
and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.groupStageMatches.isComplete, false),
or(
inArray(schema.groupStageMatches.participant1Id, draftedIds),
inArray(schema.groupStageMatches.participant2Id, draftedIds)
),
isNotNull(schema.groupStageMatches.scheduledAt),
gte(schema.groupStageMatches.scheduledAt, dateFromTimestamp),
lte(schema.groupStageMatches.scheduledAt, dateToTimestamp)
)
)
.orderBy(asc(schema.groupStageMatches.scheduledAt));
if (groupStageRows.length > 0) {
const allGroupParticipantIds = [
...new Set(groupStageRows.flatMap((r) => [r.participant1Id, r.participant2Id])),
];
const groupParticipantRows = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, allGroupParticipantIds),
});
const groupParticipantMap = new Map(groupParticipantRows.map((p) => [p.id, p]));
for (const row of groupStageRows) {
if (!row.scheduledAt) continue;
const p1 = groupParticipantMap.get(row.participant1Id);
const p2 = groupParticipantMap.get(row.participant2Id);
const relevantParticipants: Array<{ id: string; name: string }> = [];
if (draftedIds.includes(row.participant1Id) && p1) {
relevantParticipants.push({ id: row.participant1Id, name: p1.name });
}
if (draftedIds.includes(row.participant2Id) && p2) {
relevantParticipants.push({ id: row.participant2Id, name: p2.name });
}
bracketResults.push({
id: `group|${row.matchId}`,
scoringEventId: null,
name: row.scoringEventName,
eventDate: null,
earliestGameTime: row.scheduledAt.toISOString(),
matchLabel: `Group ${row.groupName}${p1?.name ?? "?"} vs ${p2?.name ?? "?"}`,
eventType: "group_stage_match",
sportsSeasonId: row.sportsSeasonId,
relevantParticipants,
});
}
}
return bracketResults;
}
export interface DashboardScoringEvent {
id: string;
name: string;
eventDate: string | null;
/** Resolved date for tab bucketing: eventDate if set, else earliest matched game date. */
displayDate: string | null;
eventStartsAt: Date | null;
eventType: string;
isComplete: boolean;
completedAt: Date | null;
sportsSeasonId: string;
sportsSeasonName: string;
sportName: string;
sportSlug: string | null;
}
/**
* Get all scoring events (excluding schedule_event type) for the given dates,
* joined with their sports season and sport info. Used for the admin dashboard
* "Games to Score" widget.
*
* An event is included if EITHER its own eventDate falls on one of the given
* dates, OR any of its playoffMatchGames has a scheduledAt timestamp on one of
* those dates. This ensures bracket events where the admin set dates on
* individual games (rather than the event itself) are still surfaced.
*/
export async function getEventsForDates(
dates: string[],
providedDb?: ReturnType<typeof database>
): Promise<DashboardScoringEvent[]> {
if (dates.length === 0) return [];
const db = providedDb || database();
// Build timestamp bounds covering the full span of the requested dates so we
// can range-check the playoffMatchGames.scheduledAt timestamp column.
const sortedDates = [...dates].toSorted();
const dateFromTimestamp = new Date(sortedDates[0] + "T00:00:00.000Z");
const dateToTimestamp = new Date(sortedDates[sortedDates.length - 1] + "T23:59:59.999Z");
// Step 1: collect event IDs where the event date OR any game's scheduledAt
// falls within the requested dates. Also capture the matched date so we can
// bucket events that have no eventDate (bracket events) into the right tab.
const rows = await db
.select({
id: schema.scoringEvents.id,
eventDate: schema.scoringEvents.eventDate,
gameDate: sql<string | null>`DATE(${schema.playoffMatchGames.scheduledAt} AT TIME ZONE 'UTC')`,
})
.from(schema.scoringEvents)
.leftJoin(
schema.playoffMatches,
eq(schema.playoffMatches.scoringEventId, schema.scoringEvents.id)
)
.leftJoin(
schema.playoffMatchGames,
eq(schema.playoffMatchGames.playoffMatchId, schema.playoffMatches.id)
)
.where(
and(
inArray(schema.scoringEvents.eventType, [
"playoff_game",
"major_tournament",
"final_standings",
]),
or(
inArray(schema.scoringEvents.eventDate, dates),
and(
isNotNull(schema.playoffMatchGames.scheduledAt),
gte(schema.playoffMatchGames.scheduledAt, dateFromTimestamp),
lte(schema.playoffMatchGames.scheduledAt, dateToTimestamp)
)
)
)
);
if (rows.length === 0) return [];
// Collect ALL dates each event qualifies for. For bracket events, the
// game's scheduledAt date (gameDate) takes priority over the event-level
// eventDate — the event-level date might be set to the overall round date
// (e.g. "April 5") while an individual game is actually scheduled today.
// Using a Set per event also de-duplicates naturally when multiple matches
// in the same bracket have games on the same day.
const eventDatesMap = new Map<string, Set<string>>();
for (const row of rows) {
if (!eventDatesMap.has(row.id)) {
eventDatesMap.set(row.id, new Set());
}
const dateSet = eventDatesMap.get(row.id);
if (!dateSet) continue;
if (row.eventDate && dates.includes(row.eventDate)) {
dateSet.add(row.eventDate);
}
if (row.gameDate && dates.includes(row.gameDate)) {
dateSet.add(row.gameDate);
}
// Fallback: row passed the WHERE clause so at least one date is relevant;
// if neither mapped to a requested date (shouldn't happen), keep something.
if (dateSet.size === 0) {
const fallback = row.gameDate ?? row.eventDate ?? null;
if (fallback) dateSet.add(fallback);
}
}
const eventIds = [...eventDatesMap.keys()];
// Step 2: fetch the matched events with sport season + sport info.
const events = await db.query.scoringEvents.findMany({
where: inArray(schema.scoringEvents.id, eventIds),
orderBy: [
asc(schema.scoringEvents.eventDate),
asc(schema.scoringEvents.eventStartsAt),
asc(schema.scoringEvents.createdAt),
],
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
// Return one entry per (event, date) pair so that an event with games on
// both today and tomorrow appears in both tabs of the dashboard widget.
return events.flatMap((e) => {
const matchingDates = [...(eventDatesMap.get(e.id) ?? [])].toSorted();
const base = {
id: e.id,
name: e.name,
eventDate: e.eventDate,
eventStartsAt: e.eventStartsAt,
eventType: e.eventType,
isComplete: e.isComplete,
completedAt: e.completedAt,
sportsSeasonId: e.sportsSeasonId,
sportsSeasonName: e.sportsSeason.name,
sportName: e.sportsSeason.sport.name,
sportSlug: e.sportsSeason.sport.slug,
};
if (matchingDates.length === 0) {
return [{ ...base, displayDate: e.eventDate ?? null }];
}
return matchingDates.map((date) => ({ ...base, displayDate: date }));
});
}
/**
* Bulk create scoring events for a sports season.
* Returns created events in order.
*/
export async function bulkCreateScoringEvents(
sportsSeasonId: string,
events: Array<{ name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; tournamentId?: string }>,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
if (events.length === 0) return [];
const rows = events.map((e) => ({
sportsSeasonId,
name: e.name,
eventDate: e.eventDate ? e.eventDate.toISOString().split("T")[0] : undefined,
eventStartsAt: e.eventStartsAt,
eventType: e.eventType,
isQualifyingEvent: e.isQualifyingEvent ?? false,
tournamentId: e.tournamentId,
isComplete: false,
}));
return db.insert(schema.scoringEvents).values(rows).returning();
}
export async function getTournamentsBySportsSeason(sportsSeasonId: string) {
const db = database();
const rows = await db.query.scoringEvents.findMany({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
isNotNull(schema.scoringEvents.tournamentId)
),
with: { tournament: true },
});
const seen = new Set<string>();
return rows.filter((r) => {
if (!r.tournamentId || seen.has(r.tournamentId)) return false;
seen.add(r.tournamentId);
return true;
});
}
export async function getSportsSeasonsByTournament(tournamentId: string) {
const db = database();
const rows = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.tournamentId, tournamentId),
with: { sportsSeason: { with: { sport: true } } },
});
const seen = new Set<string>();
return rows.filter((r) => {
if (seen.has(r.sportsSeasonId)) return false;
seen.add(r.sportsSeasonId);
return true;
});
}
/**
* Count the distinct sports-season windows linked to a tournament. Cheaper than
* getSportsSeasonsByTournament when only the count is needed (no relations).
*/
export async function countWindowsByTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
): Promise<number> {
const db = providedDb || database();
const [row] = await db
.select({
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
})
.from(schema.scoringEvents)
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
return row?.count ?? 0;
}
/**
* The primary scoring event for a tournament — the single window where the admin
* builds the bracket/stages and scoring happens; its results fan out to siblings.
* Returns the explicitly-flagged primary, falling back to the earliest-created
* linked event so callers (league/sim loaders) always have a structure source
* even before a primary is designated.
*/
export async function getPrimaryEventForTournament(
tournamentId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const primary = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (primary) return primary;
return db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.tournamentId, tournamentId),
orderBy: asc(schema.scoringEvents.createdAt),
});
}
/**
* A tournament-linked, non-primary event is a read-only mirror: its results are
* owned by the shared major's primary window and fan out to it. Direct scoring
* on such an event must be rejected so windows can't diverge. Single source of
* truth for that predicate, shared by the admin route guards and the loaders.
*/
export function isReadOnlySibling(event: {
tournamentId: string | null;
isPrimary: boolean;
}): boolean {
return !!event.tournamentId && !event.isPrimary;
}
/**
* Make `eventId` the single primary window for its tournament — set it primary
* and clear the flag on every sibling. Used by the "Make this the primary
* window" admin action and to seed a primary when the first bracket-major event
* is created.
*/
export async function setPrimaryEvent(
eventId: string,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
const event = await db.query.scoringEvents.findFirst({
where: eq(schema.scoringEvents.id, eventId),
});
if (!event?.tournamentId) {
throw new Error(`Event ${eventId} is not linked to a tournament`);
}
await db
.update(schema.scoringEvents)
.set({ isPrimary: false, updatedAt: new Date() })
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
}
/**
* Designate `eventId` as the tournament's primary window only if none is set
* yet. Idempotent: returns the existing primary's id when one already exists, so
* creating/linking additional windows never steals primary from the first.
*/
export async function ensurePrimaryEvent(
tournamentId: string,
eventId: string,
providedDb?: ReturnType<typeof database>
): Promise<string> {
const db = providedDb || database();
const existing = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.tournamentId, tournamentId),
eq(schema.scoringEvents.isPrimary, true)
),
});
if (existing) return existing.id;
await db
.update(schema.scoringEvents)
.set({ isPrimary: true, updatedAt: new Date() })
.where(eq(schema.scoringEvents.id, eventId));
return eventId;
}