587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
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(),
|
|
excludeFromHomepage: z.boolean().optional(),
|
|
})
|
|
),
|
|
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<typeof ImportDataSchema>;
|
|
|
|
// ─── Export ───────────────────────────────────────────────────────────────────
|
|
|
|
export async function exportSportsDataToJSON(): Promise<ExportData> {
|
|
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,
|
|
excludeFromHomepage: s.excludeFromHomepage,
|
|
})),
|
|
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<string, string>();
|
|
const sportsSeasonIdMap = new Map<string, string>();
|
|
// key: `${sportsSeasonId}:${participantName}` — built during participant
|
|
// import so EV/result sections avoid a per-record lookup (fixes N+1)
|
|
const participantIdMap = new Map<string, string>();
|
|
|
|
// ── 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,
|
|
excludeFromHomepage: sport.excludeFromHomepage ?? existing.excludeFromHomepage,
|
|
})
|
|
.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,
|
|
excludeFromHomepage: sport.excludeFromHomepage ?? false,
|
|
})
|
|
.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 };
|
|
});
|
|
}
|