import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm"; import type { BracketRegion } from "~/lib/bracket-templates"; import { recalculateAffectedLeagues } from "./scoring-calculator"; import { recalculateParticipantQP } from "./qualifying-points"; export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event"; export function getEventTypeLabel(eventType: string): string { switch (eventType) { case "playoff_game": return "Bracket"; case "major_tournament": return "Major Tournament"; case "final_standings": return "Final Standings"; case "schedule_event": return "Non-Scoring"; default: return eventType; } } export interface CreateScoringEventData { sportsSeasonId: string; name: string; eventDate?: Date; eventStartsAt?: Date; eventType: EventType; isQualifyingEvent?: boolean; // 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; } /** * 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, 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: { participant: 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: any = { 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; 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 async function deleteScoringEvent( eventId: string, providedDb?: ReturnType ) { const db = providedDb || database(); const event = await db.query.scoringEvents.findFirst({ where: eq(schema.scoringEvents.id, eventId), }); if (!event) return; // 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.participantId); wasQPProcessed = results.some( (r) => r.qualifyingPointsAwarded && parseFloat(r.qualifyingPointsAwarded) > 0 ); } await db.transaction(async (tx) => { if (event.eventType === "playoff_game") { await tx .delete(schema.participantResults) .where(eq(schema.participantResults.sportsSeasonId, event.sportsSeasonId)); } // Cascades: playoffMatches, eventResults, tournamentGroups/Members await tx.delete(schema.scoringEvents).where(eq(schema.scoringEvents.id, eventId)); }); if (event.eventType === "playoff_game") { 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); } } /** * 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 eventDate ascending (soonest first). * Events without a date are included last. */ export async function getUpcomingScoringEvents( sportsSeasonId: string, limit = 5, providedDb?: ReturnType ) { const db = providedDb || database(); return 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, }); } /** * 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; 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. "Semifinals Game #1" — derived from playoffMatches.round and * playoffMatchGames.gameNumber. null when unavailable or redundant with event name. */ matchLabel: string | null; eventType: string; sportsSeasonId: string; /** 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 }>, dateFrom: Date, dateTo: Date, providedDb?: ReturnType ): Promise { if (draftedParticipants.length === 0) return []; const db = providedDb || database(); const dateFromStr = dateFrom.toISOString().split("T")[0]; const dateToStr = dateTo.toISOString().split("T")[0]; const draftedIds = draftedParticipants.map((p) => p.id); const draftedMap = new Map(draftedParticipants.map((p) => [p.id, p])); if (ALL_COMPETE_PATTERNS.has(scoringPattern)) { const events = 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)], }); return events.map((e) => ({ id: e.id, name: e.name, eventDate: e.eventDate, earliestGameTime: 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. // // Use start-of-day for the timestamp lower bound so games that already happened // earlier today are still included. const dateFromTimestamp = new Date(dateFromStr + "T00:00:00.000Z"); const dateToTimestamp = new Date(dateToStr + "T23:59:59.999Z"); 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, 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), or( inArray(schema.playoffMatches.participant1Id, draftedIds), // participant2Id is nullable UUID — same underlying type as participant1Id; // the 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)); // Group rows by event, collecting relevant participants, earliest game time, and match label. const eventMap = new Map(); const participantsByEvent = new Map>(); // Unique "round|gameNumber" combos per event — used to build matchLabel const gameKeysByEvent = new Map>(); // Min scheduledAt per event const earliestTimeByEvent = new Map(); for (const row of rows) { if (!eventMap.has(row.id)) { eventMap.set(row.id, { id: row.id, name: row.name, eventDate: row.eventDate, earliestGameTime: null, matchLabel: null, eventType: row.eventType, sportsSeasonId: row.sportsSeasonId, relevantParticipants: [], }); participantsByEvent.set(row.id, new Map()); gameKeysByEvent.set(row.id, new Set()); } const pMap = participantsByEvent.get(row.id)!; if (row.participant1Id && draftedMap.has(row.participant1Id)) { pMap.set(row.participant1Id, draftedMap.get(row.participant1Id)!); } if (row.participant2Id && draftedMap.has(row.participant2Id)) { pMap.set(row.participant2Id, draftedMap.get(row.participant2Id)!); } if (row.round && row.gameNumber != null) { gameKeysByEvent.get(row.id)!.add(`${row.round}|${row.gameNumber}`); } if (row.scheduledAt) { const prev = earliestTimeByEvent.get(row.id); if (!prev || row.scheduledAt < prev) { earliestTimeByEvent.set(row.id, row.scheduledAt); } } } for (const [eventId, event] of eventMap) { event.relevantParticipants = Array.from(participantsByEvent.get(eventId)!.values()); const earliest = earliestTimeByEvent.get(eventId); event.earliestGameTime = earliest ? earliest.toISOString() : null; // Build matchLabel from game keys const gameKeys = gameKeysByEvent.get(eventId)!; if (gameKeys.size === 1) { const [round, gameNumberStr] = [...gameKeys][0].split("|"); // When round name duplicates the event name, omit the round to avoid // "Knockout Stage — Knockout Stage Game #1"; just show "Game #1" instead. event.matchLabel = round === event.name ? `Game #${gameNumberStr}` : `${round} Game #${gameNumberStr}`; } else if (gameKeys.size > 1) { // Multiple games — use just the round if it differs from the event name const rounds = new Set([...gameKeys].map((k) => k.split("|")[0])); if (rounds.size === 1) { const round = [...rounds][0]; event.matchLabel = round === event.name ? null : round; } } } return Array.from(eventMap.values()); } export interface DashboardScoringEvent { id: string; name: string; eventDate: 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].sort(); 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 distinct event IDs where the event date OR any game's // scheduledAt falls within the requested dates. const rows = await db .selectDistinct({ id: schema.scoringEvents.id }) .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 []; const eventIds = rows.map((r) => r.id); // 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 events.map((e) => ({ 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, })); } /** * 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 }>, 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, isComplete: false, })); return db.insert(schema.scoringEvents).values(rows).returning(); }