brackt/app/utils/sports-data-sync.server.ts
2026-05-19 23:02:58 -07:00

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 };
});
}