import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import { z } from "zod"; // ─── Validation schema ──────────────────────────────────────────────────────── // Used both for runtime validation of imported files and as the canonical shape // for the exported JSON (fixes: zod validation, calculatedAt, participantResults) const ImportDataSchema = z.object({ version: z.string(), exportedAt: z.string(), sports: z.array( z.object({ name: z.string(), type: z.string(), slug: z.string(), description: z.string().nullable(), }) ), sportsSeasons: z.array( z.object({ sportSlug: z.string(), name: z.string(), year: z.number(), startDate: z.string().nullable(), endDate: z.string().nullable(), status: z.string(), scoringType: z.string(), }) ), participants: z.array( z.object({ sportSlug: z.string(), seasonName: z.string(), seasonYear: z.number(), name: z.string(), shortName: z.string().nullable(), externalId: z.string().nullable(), expectedValue: z.union([z.number(), z.string()]), }) ), // Optional for backward compatibility with v1.0 exports participantExpectedValues: z .array( z.object({ sportSlug: z.string(), seasonName: z.string(), seasonYear: z.number(), participantName: z.string(), probFirst: z.string(), probSecond: z.string(), probThird: z.string(), probFourth: z.string(), probFifth: z.string(), probSixth: z.string(), probSeventh: z.string(), probEighth: z.string(), expectedValue: z.string(), source: z.string().nullable(), sourceOdds: z.number().nullable(), calculatedAt: z.string().optional(), // optional for backward compat }) ) .optional(), participantResults: z .array( z.object({ sportSlug: z.string(), seasonName: z.string(), seasonYear: z.number(), participantName: z.string(), finalPosition: z.number().nullable(), qualifyingPoints: z.string().nullable(), notes: z.string().nullable(), }) ) .optional(), seasonTemplates: z.array( z.object({ name: z.string(), year: z.number(), description: z.string().nullable(), isActive: z.boolean(), sports: z.array( z.object({ sportSlug: z.string(), seasonName: z.string(), seasonYear: z.number(), }) ), }) ), }); type ExportData = z.infer; // ─── Export ─────────────────────────────────────────────────────────────────── export async function exportSportsDataToJSON(): Promise { const db = database(); const [ sports, sportsSeasons, participants, participantEVs, participantResultsData, seasonTemplates, ] = await Promise.all([ db.query.sports.findMany({ orderBy: (s, { asc }) => [asc(s.name)], }), db.query.sportsSeasons.findMany({ with: { sport: true }, orderBy: (s, { desc, asc }) => [desc(s.year), asc(s.name)], }), db.query.seasonParticipants.findMany({ with: { sportsSeason: { with: { sport: true } } }, orderBy: (p, { asc }) => [asc(p.name)], }), db.query.seasonParticipantExpectedValues.findMany({ with: { participant: { with: { sportsSeason: { with: { sport: true } } } }, }, }), db.query.seasonParticipantResults.findMany({ with: { participant: { with: { sportsSeason: { with: { sport: true } } } }, }, }), db.query.seasonTemplates.findMany({ with: { seasonTemplateSports: { with: { sportsSeason: { with: { sport: true } } }, }, }, orderBy: (t, { desc }) => [desc(t.year)], }), ]); return { version: "1.1", exportedAt: new Date().toISOString(), sports: sports.map((s) => ({ name: s.name, type: s.type, slug: s.slug, description: s.description, })), sportsSeasons: sportsSeasons.map((s) => ({ sportSlug: s.sport.slug, name: s.name, year: s.year, startDate: s.startDate, endDate: s.endDate, status: s.status, scoringType: s.scoringType, })), participants: participants.map((p) => ({ sportSlug: p.sportsSeason.sport.slug, seasonName: p.sportsSeason.name, seasonYear: p.sportsSeason.year, name: p.name, shortName: p.shortName, externalId: p.externalId, expectedValue: p.expectedValue, })), participantExpectedValues: participantEVs.map((ev) => ({ sportSlug: ev.participant.sportsSeason.sport.slug, seasonName: ev.participant.sportsSeason.name, seasonYear: ev.participant.sportsSeason.year, participantName: ev.participant.name, probFirst: ev.probFirst, probSecond: ev.probSecond, probThird: ev.probThird, probFourth: ev.probFourth, probFifth: ev.probFifth, probSixth: ev.probSixth, probSeventh: ev.probSeventh, probEighth: ev.probEighth, expectedValue: ev.expectedValue, source: ev.source, sourceOdds: ev.sourceOdds, calculatedAt: ev.calculatedAt.toISOString(), })), participantResults: participantResultsData.map((r) => ({ sportSlug: r.participant.sportsSeason.sport.slug, seasonName: r.participant.sportsSeason.name, seasonYear: r.participant.sportsSeason.year, participantName: r.participant.name, finalPosition: r.finalPosition, qualifyingPoints: r.qualifyingPoints, notes: r.notes, })), seasonTemplates: seasonTemplates.map((t) => ({ name: t.name, year: t.year, description: t.description, isActive: t.isActive, sports: t.seasonTemplateSports.map((ts) => ({ sportSlug: ts.sportsSeason.sport.slug, seasonName: ts.sportsSeason.name, seasonYear: ts.sportsSeason.year, })), })), }; } // ─── Import ─────────────────────────────────────────────────────────────────── export async function importSportsDataFromJSON( jsonData: string, mode: "merge" | "replace" = "merge" ): Promise<{ created: number; updated: number; skipped: number }> { const db = database(); // Validate before touching the database — gives a clear error on bad files let importData: ExportData; try { importData = ImportDataSchema.parse(JSON.parse(jsonData)); } catch (err) { throw new Error( `Invalid import file: ${err instanceof Error ? err.message : "unknown validation error"}`, { cause: err } ); } // Wrap everything in a transaction so a mid-import failure rolls back cleanly return await db.transaction(async (tx) => { let created = 0; let updated = 0; let skipped = 0; if (mode === "replace") { // Delete in reverse dependency order. // participantExpectedValues and participantResults both carry // onDelete: "cascade" on their participantId FK, so they are // automatically removed when participants is deleted. await tx.delete(schema.seasonTemplateSports); await tx.delete(schema.seasonTemplates); await tx.delete(schema.seasonParticipants); // cascades to EVs + results await tx.delete(schema.seasonSports); await tx.delete(schema.sportsSeasons); await tx.delete(schema.sports); } // ID maps used to resolve relationships without further queries const sportIdMap = new Map(); const sportsSeasonIdMap = new Map(); // key: `${sportsSeasonId}:${participantName}` — built during participant // import so EV/result sections avoid a per-record lookup (fixes N+1) const participantIdMap = new Map(); // ── Sports ────────────────────────────────────────────────────────────── for (const sport of importData.sports) { const existing = await tx.query.sports.findFirst({ where: eq(schema.sports.slug, sport.slug), }); if (existing) { if (mode === "merge") { await tx .update(schema.sports) .set({ name: sport.name, type: sport.type as "team" | "individual", description: sport.description, }) .where(eq(schema.sports.slug, sport.slug)); sportIdMap.set(sport.slug, existing.id); updated++; } } else { const [created_] = await tx .insert(schema.sports) .values({ name: sport.name, type: sport.type as "team" | "individual", slug: sport.slug, description: sport.description, }) .returning(); sportIdMap.set(sport.slug, created_.id); created++; } } // ── Sports seasons ─────────────────────────────────────────────────────── for (const season of importData.sportsSeasons) { const sportId = sportIdMap.get(season.sportSlug); if (!sportId) { skipped++; continue; } const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`; const existing = await tx.query.sportsSeasons.findFirst({ where: and( eq(schema.sportsSeasons.sportId, sportId), eq(schema.sportsSeasons.name, season.name), eq(schema.sportsSeasons.year, season.year) ), }); if (existing) { if (mode === "merge") { await tx .update(schema.sportsSeasons) .set({ startDate: season.startDate, endDate: season.endDate, status: season.status as "upcoming" | "active" | "completed", scoringType: season.scoringType as | "playoffs" | "regular_season" | "majors", }) .where(eq(schema.sportsSeasons.id, existing.id)); sportsSeasonIdMap.set(seasonKey, existing.id); updated++; } } else { const [created_] = await tx .insert(schema.sportsSeasons) .values({ sportId, name: season.name, year: season.year, startDate: season.startDate, endDate: season.endDate, status: season.status as "upcoming" | "active" | "completed", scoringType: season.scoringType as | "playoffs" | "regular_season" | "majors", // Placeholder: season is not draftable until an admin sets real dates. // A narrow past window is used so the season never accidentally appears // in league creation without an intentional update. draftOn: "2000-01-01", draftOff: "2000-01-02", }) .returning(); sportsSeasonIdMap.set(seasonKey, created_.id); created++; } } // ── Participants ───────────────────────────────────────────────────────── for (const participant of importData.participants) { const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`; const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); if (!sportsSeasonId) { skipped++; continue; } const existing = await tx.query.seasonParticipants.findFirst({ where: and( eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId), eq(schema.seasonParticipants.name, participant.name) ), }); if (existing) { // Update all mutable fields (fixes: merge mode was silently skipping) await tx .update(schema.seasonParticipants) .set({ shortName: participant.shortName, externalId: participant.externalId, expectedValue: String(participant.expectedValue ?? 0), }) .where(eq(schema.seasonParticipants.id, existing.id)); participantIdMap.set(`${sportsSeasonId}:${participant.name}`, existing.id); updated++; } else { const [created_] = await tx .insert(schema.seasonParticipants) .values({ sportsSeasonId, name: participant.name, shortName: participant.shortName, externalId: participant.externalId, expectedValue: String(participant.expectedValue ?? 0), }) .returning(); participantIdMap.set(`${sportsSeasonId}:${participant.name}`, created_.id); created++; } } // ── Participant expected values ────────────────────────────────────────── if (importData.participantExpectedValues) { for (const ev of importData.participantExpectedValues) { const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`; const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); if (!sportsSeasonId) { skipped++; continue; } // Use map built during participant import — no extra query needed const participantId = participantIdMap.get( `${sportsSeasonId}:${ev.participantName}` ); if (!participantId) { skipped++; continue; } const existingEV = await tx.query.seasonParticipantExpectedValues.findFirst({ where: and( eq(schema.seasonParticipantExpectedValues.participantId, participantId), eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) ), }); const evValues = { probFirst: ev.probFirst, probSecond: ev.probSecond, probThird: ev.probThird, probFourth: ev.probFourth, probFifth: ev.probFifth, probSixth: ev.probSixth, probSeventh: ev.probSeventh, probEighth: ev.probEighth, expectedValue: ev.expectedValue, source: ev.source as | "manual" | "futures_odds" | "elo_simulation" | "performance_model" | null, sourceOdds: ev.sourceOdds, // Restore original timestamp when present (fixes: calculatedAt lost) ...(ev.calculatedAt ? { calculatedAt: new Date(ev.calculatedAt) } : {}), }; if (existingEV) { if (mode === "merge") { await tx .update(schema.seasonParticipantExpectedValues) .set({ ...evValues, updatedAt: new Date() }) .where(eq(schema.seasonParticipantExpectedValues.id, existingEV.id)); await tx .update(schema.seasonParticipants) .set({ expectedValue: ev.expectedValue }) .where(eq(schema.seasonParticipants.id, participantId)); updated++; } } else { await tx.insert(schema.seasonParticipantExpectedValues).values({ participantId, sportsSeasonId, ...evValues, }); await tx .update(schema.seasonParticipants) .set({ expectedValue: ev.expectedValue }) .where(eq(schema.seasonParticipants.id, participantId)); created++; } } } // ── Participant results ────────────────────────────────────────────────── if (importData.participantResults) { for (const result of importData.participantResults) { const seasonKey = `${result.sportSlug}:${result.seasonName}:${result.seasonYear}`; const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); if (!sportsSeasonId) { skipped++; continue; } const participantId = participantIdMap.get( `${sportsSeasonId}:${result.participantName}` ); if (!participantId) { skipped++; continue; } const existingResult = await tx.query.seasonParticipantResults.findFirst({ where: and( eq(schema.seasonParticipantResults.participantId, participantId), eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId) ), }); const resultValues = { finalPosition: result.finalPosition, qualifyingPoints: result.qualifyingPoints, notes: result.notes, }; if (existingResult) { if (mode === "merge") { await tx .update(schema.seasonParticipantResults) .set({ ...resultValues, updatedAt: new Date() }) .where(eq(schema.seasonParticipantResults.id, existingResult.id)); updated++; } } else { await tx.insert(schema.seasonParticipantResults).values({ participantId, sportsSeasonId, ...resultValues, }); created++; } } } // ── Season templates ───────────────────────────────────────────────────── for (const template of importData.seasonTemplates) { const existing = await tx.query.seasonTemplates.findFirst({ where: and( eq(schema.seasonTemplates.name, template.name), eq(schema.seasonTemplates.year, template.year) ), }); let templateId: string; if (existing) { if (mode === "merge") { await tx .update(schema.seasonTemplates) .set({ description: template.description, isActive: template.isActive, }) .where(eq(schema.seasonTemplates.id, existing.id)); templateId = existing.id; updated++; // Remove existing template sports to re-add them cleanly await tx .delete(schema.seasonTemplateSports) .where(eq(schema.seasonTemplateSports.templateId, templateId)); } else { continue; } } else { const [created_] = await tx .insert(schema.seasonTemplates) .values({ name: template.name, year: template.year, description: template.description, isActive: template.isActive, }) .returning(); templateId = created_.id; created++; } for (const templateSport of template.sports) { const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`; const sportsSeasonId = sportsSeasonIdMap.get(seasonKey); if (sportsSeasonId) { await tx.insert(schema.seasonTemplateSports).values({ templateId, sportsSeasonId, }); } } } return { created, updated, skipped }; }); }