import { eq, inArray, 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 { findTournamentsBySportsSeason } from "~/models/sports-season-tournament"; 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 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) ), 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 updateSportsSeason( id: string, data: Partial ): Promise { const db = database(); const [sportsSeason] = await db .update(schema.sportsSeasons) .set({ ...data, updatedAt: new Date() }) .where(eq(schema.sportsSeasons.id, id)) .returning(); 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), scoring events (dates shifted * by yearDelta), futures odds + Elo ratings (as stubs for re-simulation), * and QP config (for qualifying_points seasons). */ export async function cloneSportsSeason( sourceId: string, newData: NewSportsSeason ): 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; // Read source futures odds and Elo ratings const sourceEvRows = await db.query.seasonParticipantExpectedValues.findMany({ where: eq(schema.seasonParticipantExpectedValues.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 (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 (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, 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 ); } const sourceTournamentLinks = await findTournamentsBySportsSeason(sourceId); if (sourceTournamentLinks.length > 0) { await tx.insert(schema.sportsSeasonTournaments).values( sourceTournamentLinks.map((link: typeof sourceTournamentLinks[number]) => ({ sportsSeasonId: newSeason.id, tournamentId: link.tournamentId, })) ); } return newSeason; }); }