* feat: sync odds-based EV to participants table for draft room ranking When admin enters futures odds or manual probabilities, the calculated EV is now synced from participantExpectedValues to participants.expectedValue. This closes the gap where EVs were calculated but never reflected in the draft room ranking. Also changes expectedValue from integer to decimal(10,2) for better precision, and displays "—" for participants with no odds data. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T * fix: correct expectedValue type to string in participants route Missed instance of `expectedValue: 0` (number) that failed typecheck after schema change from integer to decimal. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T --------- Co-authored-by: Claude <noreply@anthropic.com>
334 lines
9.1 KiB
TypeScript
334 lines
9.1 KiB
TypeScript
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
|
|
interface ExportData {
|
|
version: string;
|
|
exportedAt: string;
|
|
sports: Array<{
|
|
name: string;
|
|
type: string;
|
|
slug: string;
|
|
description: string | null;
|
|
}>;
|
|
sportsSeasons: Array<{
|
|
sportSlug: string;
|
|
name: string;
|
|
year: number;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
status: string;
|
|
scoringType: string;
|
|
}>;
|
|
participants: Array<{
|
|
sportSlug: string;
|
|
seasonName: string;
|
|
seasonYear: number;
|
|
name: string;
|
|
shortName: string | null;
|
|
externalId: string | null;
|
|
expectedValue: number | string;
|
|
}>;
|
|
seasonTemplates: Array<{
|
|
name: string;
|
|
year: number;
|
|
description: string | null;
|
|
isActive: boolean;
|
|
sports: Array<{
|
|
sportSlug: string;
|
|
seasonName: string;
|
|
seasonYear: number;
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
export async function exportSportsDataToJSON(): Promise<ExportData> {
|
|
const db = database();
|
|
|
|
// Export sports
|
|
const sports = await db.query.sports.findMany({
|
|
orderBy: (sports, { asc }) => [asc(sports.name)],
|
|
});
|
|
|
|
// Export sports seasons with sport relation
|
|
const sportsSeasons = await db.query.sportsSeasons.findMany({
|
|
with: {
|
|
sport: true,
|
|
},
|
|
orderBy: (sportsSeasons, { desc, asc }) => [
|
|
desc(sportsSeasons.year),
|
|
asc(sportsSeasons.name),
|
|
],
|
|
});
|
|
|
|
// Export participants with relations
|
|
const participants = await db.query.participants.findMany({
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: (participants, { asc }) => [asc(participants.name)],
|
|
});
|
|
|
|
// Export season templates with sports
|
|
const seasonTemplates = await db.query.seasonTemplates.findMany({
|
|
with: {
|
|
seasonTemplateSports: {
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: (templates, { desc }) => [desc(templates.year)],
|
|
});
|
|
|
|
// Build export data structure
|
|
return {
|
|
version: "1.0",
|
|
exportedAt: new Date().toISOString(),
|
|
sports: sports.map((sport) => ({
|
|
name: sport.name,
|
|
type: sport.type,
|
|
slug: sport.slug,
|
|
description: sport.description,
|
|
})),
|
|
sportsSeasons: sportsSeasons.map((season) => ({
|
|
sportSlug: season.sport.slug,
|
|
name: season.name,
|
|
year: season.year,
|
|
startDate: season.startDate,
|
|
endDate: season.endDate,
|
|
status: season.status,
|
|
scoringType: season.scoringType,
|
|
})),
|
|
participants: participants.map((participant) => ({
|
|
sportSlug: participant.sportsSeason.sport.slug,
|
|
seasonName: participant.sportsSeason.name,
|
|
seasonYear: participant.sportsSeason.year,
|
|
name: participant.name,
|
|
shortName: participant.shortName,
|
|
externalId: participant.externalId,
|
|
expectedValue: participant.expectedValue,
|
|
})),
|
|
seasonTemplates: seasonTemplates.map((template) => ({
|
|
name: template.name,
|
|
year: template.year,
|
|
description: template.description,
|
|
isActive: template.isActive,
|
|
sports: template.seasonTemplateSports.map((ts) => ({
|
|
sportSlug: ts.sportsSeason.sport.slug,
|
|
seasonName: ts.sportsSeason.name,
|
|
seasonYear: ts.sportsSeason.year,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function importSportsDataFromJSON(
|
|
jsonData: string,
|
|
mode: "merge" | "replace" = "merge"
|
|
): Promise<{ created: number; updated: number; skipped: number }> {
|
|
const db = database();
|
|
const importData: ExportData = JSON.parse(jsonData);
|
|
|
|
let created = 0;
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
|
|
if (mode === "replace") {
|
|
// Delete in reverse order of dependencies
|
|
await db.delete(schema.seasonTemplateSports);
|
|
await db.delete(schema.seasonTemplates);
|
|
await db.delete(schema.participantResults);
|
|
await db.delete(schema.participants);
|
|
await db.delete(schema.seasonSports);
|
|
await db.delete(schema.sportsSeasons);
|
|
await db.delete(schema.sports);
|
|
}
|
|
|
|
// Track created IDs for relationships
|
|
const sportIdMap = new Map<string, string>();
|
|
const sportsSeasonIdMap = new Map<string, string>();
|
|
|
|
// Import sports
|
|
for (const sport of importData.sports) {
|
|
const existing = await db.query.sports.findFirst({
|
|
where: eq(schema.sports.slug, sport.slug),
|
|
});
|
|
|
|
if (existing) {
|
|
if (mode === "merge") {
|
|
await db
|
|
.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 [createdSport] = await db
|
|
.insert(schema.sports)
|
|
.values({
|
|
name: sport.name,
|
|
type: sport.type as "team" | "individual",
|
|
slug: sport.slug,
|
|
description: sport.description,
|
|
})
|
|
.returning();
|
|
sportIdMap.set(sport.slug, createdSport.id);
|
|
created++;
|
|
}
|
|
}
|
|
|
|
// Import sports seasons
|
|
for (const season of importData.sportsSeasons) {
|
|
const sportId = sportIdMap.get(season.sportSlug);
|
|
if (!sportId) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const existing = await db.query.sportsSeasons.findFirst({
|
|
where: and(
|
|
eq(schema.sportsSeasons.sportId, sportId),
|
|
eq(schema.sportsSeasons.name, season.name),
|
|
eq(schema.sportsSeasons.year, season.year)
|
|
),
|
|
});
|
|
|
|
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
|
|
|
|
if (existing) {
|
|
if (mode === "merge") {
|
|
await db
|
|
.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 [createdSeason] = await db
|
|
.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",
|
|
})
|
|
.returning();
|
|
sportsSeasonIdMap.set(seasonKey, createdSeason.id);
|
|
created++;
|
|
}
|
|
}
|
|
|
|
// Import 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 db.query.participants.findFirst({
|
|
where: and(
|
|
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
|
eq(schema.participants.name, participant.name)
|
|
),
|
|
});
|
|
|
|
if (!existing) {
|
|
await db.insert(schema.participants).values({
|
|
sportsSeasonId,
|
|
name: participant.name,
|
|
shortName: participant.shortName,
|
|
externalId: participant.externalId,
|
|
expectedValue: String(participant.expectedValue ?? 0),
|
|
});
|
|
created++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
// Import season templates
|
|
for (const template of importData.seasonTemplates) {
|
|
const existing = await db.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 db
|
|
.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
|
|
await db
|
|
.delete(schema.seasonTemplateSports)
|
|
.where(eq(schema.seasonTemplateSports.templateId, templateId));
|
|
} else {
|
|
continue;
|
|
}
|
|
} else {
|
|
const [createdTemplate] = await db
|
|
.insert(schema.seasonTemplates)
|
|
.values({
|
|
name: template.name,
|
|
year: template.year,
|
|
description: template.description,
|
|
isActive: template.isActive,
|
|
})
|
|
.returning();
|
|
templateId = createdTemplate.id;
|
|
created++;
|
|
}
|
|
|
|
// Add template sports
|
|
for (const templateSport of template.sports) {
|
|
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
|
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
|
|
|
if (sportsSeasonId) {
|
|
await db.insert(schema.seasonTemplateSports).values({
|
|
templateId,
|
|
sportsSeasonId,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { created, updated, skipped };
|
|
}
|