import { eq, inArray, isNull, sql, lte, gte } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { getQPConfig, updateQPConfig } from "~/models/qualifying-points"; import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server"; export async function countSportsSeasons(): Promise { const db = database(); const [{ count }] = await db .select({ count: sql`count(*)::int` }) .from(schema.sportsSeasons); return count; } export type SportsSeason = typeof schema.sportsSeasons.$inferSelect; export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert; export type SportsSeasonWithSport = SportsSeason & { sport: typeof schema.sports.$inferSelect; }; export type SportsSeasonListItem = SportsSeasonWithSport & { participants: Array<{ id: string }>; }; export type SportsSeasonStatus = "upcoming" | "active" | "completed"; export type ScoringType = "playoffs" | "regular_season" | "majors"; export async function createSportsSeason(data: NewSportsSeason): Promise { const db = database(); const [sportsSeason] = await db .insert(schema.sportsSeasons) .values(data) .returning(); return sportsSeason; } export async function findSportsSeasonById(id: string): Promise { const db = database(); return await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, id), with: { sport: true, }, }) as SportsSeasonWithSport | undefined; } export async function findSportsSeasonsByIds(ids: string[]): Promise { if (ids.length === 0) return []; const db = database(); return await db.query.sportsSeasons.findMany({ where: inArray(schema.sportsSeasons.id, ids), with: { sport: true }, }) as SportsSeasonWithSport[]; } export async function findSportsSeasonsBySportId(sportId: string): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ where: eq(schema.sportsSeasons.sportId, sportId), orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], with: { sport: true, }, }) as SportsSeasonWithSport[]; } export async function findActiveSportsSeasonsBySportId(sportId: string): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ where: (ss, { and }) => and( eq(ss.sportId, sportId), eq(ss.status, "active") ), orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], with: { sport: true, }, }); } export async function findSportsSeasonsByYear(year: number): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ where: eq(schema.sportsSeasons.year, year), orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.name)], with: { sport: true, }, }); } export async function findSportsSeasonsByStatus(status: SportsSeasonStatus): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ where: eq(schema.sportsSeasons.status, status), orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], with: { sport: true, }, }); } export async function findAllSportsSeasons(): Promise { const db = database(); const results = await db.query.sportsSeasons.findMany({ with: { sport: true, participants: { columns: { id: true, }, }, }, }); const statusOrder: Record = { active: 0, upcoming: 1, completed: 2 }; return results.toSorted((a, b) => { const statusDiff = statusOrder[a.status] - statusOrder[b.status]; if (statusDiff !== 0) return statusDiff; const sportNameDiff = a.sport.name.localeCompare(b.sport.name); if (sportNameDiff !== 0) return sportNameDiff; return a.year - b.year; }); } export async function findAllAdminSportsSeasons(): Promise { const seasons = await findAllSportsSeasons(); // Hide per-league private copies (fantasySeasonId IS NOT NULL); show the global template return seasons.filter((season) => season.fantasySeasonId === null); } export async function findDraftableSportsSeasons() { const db = database(); const today = sql`CURRENT_DATE`; const seasons = await db.query.sportsSeasons.findMany({ where: (ss, { and }) => and( lte(ss.draftOn, today), gte(ss.draftOff, today), isNull(ss.fantasySeasonId) ), orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)], with: { sport: true, participants: { columns: { id: true } }, }, }); return seasons.map(({ participants, ...s }) => ({ ...s, participantCount: participants.length, })); } export async function findDraftableSportsSeasonBySportId(sportId: string) { const db = database(); const today = sql`CURRENT_DATE`; return await db.query.sportsSeasons.findFirst({ where: (ss, ops) => ops.and( ops.eq(ss.sportId, sportId), lte(ss.draftOn, today), gte(ss.draftOff, today), isNull(ss.fantasySeasonId) ), orderBy: (ss, { desc }) => [desc(ss.year), desc(ss.draftOn)], with: { sport: true, }, }); } export type DraftScheduleWindow = { id: string; name: string; year: number; status: SportsSeasonStatus; draftOn: string; draftOff: string; sport: { id: string; name: string; slug: string; iconUrl: string | null; }; }; /** * Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window * [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an * overlap test (not containment) so windows already open, or spanning the horizon * edges, are still included. Powers the admin draft-schedule Gantt chart. */ export async function findDraftScheduleForHorizon( monthsAhead: number ): Promise { const db = database(); const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`; const seasons = await db.query.sportsSeasons.findMany({ where: (ss, { and }) => and( isNull(ss.fantasySeasonId), gte(ss.draftOff, sql`CURRENT_DATE`), lte(ss.draftOn, horizonEnd) ), orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)], with: { sport: { columns: { id: true, name: true, slug: true, iconUrl: true }, }, }, }); return seasons.map((s) => ({ id: s.id, name: s.name, year: s.year, status: s.status, draftOn: s.draftOn, draftOff: s.draftOff, sport: s.sport, })) as DraftScheduleWindow[]; } export async function updateSportsSeason( id: string, data: Partial ): Promise { const db = database(); const previous = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, id), columns: { status: true }, }); const [sportsSeason] = await db .update(schema.sportsSeasons) .set({ ...data, updatedAt: new Date() }) .where(eq(schema.sportsSeasons.id, id)) .returning(); if (previous && previous.status !== "completed" && sportsSeason.status === "completed") { await maybeResolveCompletedBracktForSportsSeason(sportsSeason.id, db); } return sportsSeason; } export async function updateSportsSeasonStatus( id: string, status: SportsSeasonStatus ): Promise { return await updateSportsSeason(id, { status }); } export async function deleteSportsSeason(id: string): Promise { const db = database(); await db.delete(schema.sportsSeasons).where(eq(schema.sportsSeasons.id, id)); } export function shiftDateByYears(dateStr: string, years: number): string { const d = new Date(dateStr + "T12:00:00Z"); d.setUTCFullYear(d.getUTCFullYear() + years); return d.toISOString().split("T")[0]; } function shiftTimestampByYears(date: Date, years: number): Date { const d = new Date(date); d.setUTCFullYear(d.getUTCFullYear() + years); return d; } /** * Clone a sports season: creates a new season from newData, then copies * participants (name/shortName/externalId only), simulator structure/config, * scoring events (dates shifted by yearDelta), and QP config. Volatile simulator * inputs such as futures odds and Elo ratings are copied only when explicitly * requested. */ export async function cloneSportsSeason( sourceId: string, newData: NewSportsSeason, options: { copySimulatorInputs?: boolean } = {} ): Promise { const db = database(); const sourceSeason = await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, sourceId), }); if (!sourceSeason) { throw new Error(`Source season ${sourceId} not found`); } const yearDelta = newData.year - sourceSeason.year; // Merge ELO calibration fields from source (form doesn't expose them) const mergedData: NewSportsSeason = { eloCalibrationExponent: sourceSeason.eloCalibrationExponent, eloMinRating: sourceSeason.eloMinRating, eloMaxRating: sourceSeason.eloMaxRating, ...newData, }; // Read all source data before writing anything const sourceParticipants = await db.query.seasonParticipants.findMany({ where: eq(schema.seasonParticipants.sportsSeasonId, sourceId), }); const sourceEvents = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sourceId), }); const sourceQPConfig = newData.scoringPattern === "qualifying_points" ? await getQPConfig(sourceId, db) : null; const sourceSimulatorConfig = await db.query.sportsSeasonSimulatorConfigs.findFirst({ where: eq(schema.sportsSeasonSimulatorConfigs.sportsSeasonId, sourceId), }); const sourceEvRows = options.copySimulatorInputs ? await db.query.seasonParticipantExpectedValues.findMany({ where: eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sourceId), }) : []; const sourceSimulatorInputs = options.copySimulatorInputs ? await db.query.seasonParticipantSimulatorInputs.findMany({ where: eq(schema.seasonParticipantSimulatorInputs.sportsSeasonId, sourceId), }) : []; // Build lookup: (externalId ?? name) → source EV, only for rows that have // actual input data (odds or Elo) worth carrying forward type SourceEv = typeof sourceEvRows[number]; const sourceEvById = new Map(sourceEvRows.map((r) => [r.participantId, r])); const sourceEvByKey = new Map(); for (const p of sourceParticipants) { const ev = sourceEvById.get(p.id); if (ev && (ev.sourceOdds !== null || ev.sourceElo !== null)) { sourceEvByKey.set(p.externalId ?? p.name, ev); } } // All writes in a single transaction — partial clone is never committed return await db.transaction(async (tx: typeof db) => { const [newSeason] = await tx .insert(schema.sportsSeasons) .values(mergedData) .returning(); const newParticipants = sourceParticipants.length > 0 ? await tx .insert(schema.seasonParticipants) .values( sourceParticipants.map((p: typeof sourceParticipants[number]) => ({ sportsSeasonId: newSeason.id, name: p.name, shortName: p.shortName, externalId: p.externalId, participantId: p.participantId, })) ) .returning() : []; // Copy futures odds / Elo stubs matched by externalId then name if (sourceSimulatorConfig) { await tx.insert(schema.sportsSeasonSimulatorConfigs).values({ sportsSeasonId: newSeason.id, simulatorType: sourceSimulatorConfig.simulatorType, config: sourceSimulatorConfig.config, }); } if (options.copySimulatorInputs && sourceEvByKey.size > 0 && newParticipants.length > 0) { const now = new Date(); const evRows = newParticipants .map((newP: typeof newParticipants[number]) => { const key = newP.externalId ?? newP.name; const sourceEv = sourceEvByKey.get(key); if (!sourceEv) return null; return { participantId: newP.id, sportsSeasonId: newSeason.id, probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0", probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0", expectedValue: "0", source: sourceEv.source, sourceOdds: sourceEv.sourceOdds, sourceElo: sourceEv.sourceElo, worldRanking: sourceEv.worldRanking, calculatedAt: now, updatedAt: now, }; }) .filter((r): r is NonNullable => r !== null); if (evRows.length > 0) { await tx.insert(schema.seasonParticipantExpectedValues).values(evRows); } } if (options.copySimulatorInputs && sourceSimulatorInputs.length > 0 && newParticipants.length > 0) { const sourceParticipantById = new Map(sourceParticipants.map((p) => [p.id, p])); const sourceInputByKey = new Map( sourceSimulatorInputs.flatMap((input) => { const sourceParticipant = sourceParticipantById.get(input.participantId); return sourceParticipant ? [[sourceParticipant.externalId ?? sourceParticipant.name, input] as const] : []; }) ); const simulatorInputRows = newParticipants .map((newP: typeof newParticipants[number]) => { const sourceInput = sourceInputByKey.get(newP.externalId ?? newP.name); if (!sourceInput) return null; return { participantId: newP.id, sportsSeasonId: newSeason.id, sourceOdds: sourceInput.sourceOdds, sourceElo: sourceInput.sourceElo, worldRanking: sourceInput.worldRanking, rating: sourceInput.rating, projectedWins: sourceInput.projectedWins, projectedTablePoints: sourceInput.projectedTablePoints, seed: sourceInput.seed, region: sourceInput.region, metadata: sourceInput.metadata, }; }) .filter((row): row is NonNullable => row !== null); if (simulatorInputRows.length > 0) { await tx.insert(schema.seasonParticipantSimulatorInputs).values(simulatorInputRows); } } if (sourceEvents.length > 0) { await tx.insert(schema.scoringEvents).values( sourceEvents.map((e: typeof sourceEvents[number]) => ({ sportsSeasonId: newSeason.id, name: e.name, eventType: e.eventType, isQualifyingEvent: e.isQualifyingEvent, // Carries over the source season's tournament IDs. For qualifying_points seasons // cloned into a new year, qualifying events will still point to the old year's // canonical tournaments — admin should delete and recreate them once the new // year's tournaments exist. tournamentId: e.tournamentId ?? null, bracketTemplateId: e.bracketTemplateId, scoringStartsAtRound: e.scoringStartsAtRound, isComplete: false, eventDate: e.eventDate ? shiftDateByYears(e.eventDate, yearDelta) : null, eventStartsAt: e.eventStartsAt ? shiftTimestampByYears(e.eventStartsAt, yearDelta) : null, })) ); } if (sourceQPConfig) { await updateQPConfig( newSeason.id, sourceQPConfig.map((qp: typeof sourceQPConfig[number]) => ({ placement: qp.placement, points: parseFloat(qp.points), })), tx as ReturnType ); } return newSeason; }); }