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 ) { 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 ) { 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 ) { 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 ) { 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 ) { 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 ) { const db = providedDb || database(); const updateData: Partial = { 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 ) { 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, opts?: DeleteScoringEventOptions ): Promise { 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 (or no sibling is // flagged primary anymore), promote the earliest remaining window so the // shared major stays scorable and fan-out stays deterministic. const hasPrimary = remaining.some((e) => e.isPrimary); if (event.isPrimary || !hasPrimary) { 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 ): Promise { 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 ) { 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(); 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 ) { 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 ) { 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 ): Promise { 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(); const extraEventIds = new Set(); 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(); 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(); const participantsByEntry = new Map>(); const gameKeysByEntry = new Map>(); const earliestTimeByEntry = new Map(); const isSeriesByEntry = new Map(); const matchupIdsByEntry = new Map(); 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(); // 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 ): Promise { 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`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>(); 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 ) { 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(); 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(); return rows.filter((r) => { if (seen.has(r.sportsSeasonId)) return false; seen.add(r.sportsSeasonId); return true; }); } /** * 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 ) { 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 ) { 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 ): Promise { 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; }