Address all data-sync code review findings
- Wrap importSportsDataFromJSON in a db.transaction() so any mid-import failure rolls back cleanly instead of leaving partial data - Add zod validation of imported JSON before any DB writes, giving a clear error on malformed/incompatible files - Fix N+1 queries: build participantIdMap during participant import so the EV and results sections resolve IDs from memory, not extra queries - Fix merge mode silently skipping existing participants — now updates shortName, externalId, and expectedValue - Add participantResults to export/import (was deleted in replace mode but never exported, so results were permanently lost) - Restore calculatedAt timestamp on EV import instead of defaulting to now(); stored as ISO string in the export for portability - Document that onDelete:cascade covers participantExpectedValues and participantResults when participants is deleted; remove the now- redundant explicit participantResults delete from replace mode - Bump export version to 1.1; old v1.0 files still import cleanly since participantExpectedValues, participantResults, and calculatedAt are all optional in the zod schema - Collapse all six DB fetches in exportSportsDataToJSON into one Promise.all for parallel execution - Add countAllParticipants() and countAllParticipantEVs() model funcs - Show Participants and EV Records counts on the dashboard stat cards - Replace raw DOM form creation/submit in handleImport with useFetcher, giving proper loading state, no full-page reload, and type-safe data - Remove dead export intent handler from the action function https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B
This commit is contained in:
parent
dd5072a2c2
commit
e1b4f8a3aa
6 changed files with 626 additions and 444 deletions
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { participantExpectedValues, participants } from "~/database/schema";
|
import { participantExpectedValues, participants } from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, count } from "drizzle-orm";
|
||||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||||
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
|
||||||
|
|
||||||
|
|
@ -161,6 +161,12 @@ export async function upsertParticipantEVWithNormalization(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countAllParticipantEVs(): Promise<number> {
|
||||||
|
const db = database();
|
||||||
|
const result = await db.select({ value: count() }).from(participantExpectedValues);
|
||||||
|
return result[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get participant EV for a specific sports season
|
* Get participant EV for a specific sports season
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray, count } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -73,6 +73,12 @@ export async function updateParticipant(
|
||||||
return participant;
|
return participant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countAllParticipants(): Promise<number> {
|
||||||
|
const db = database();
|
||||||
|
const result = await db.select({ value: count() }).from(schema.participants);
|
||||||
|
return result[0].value;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteParticipant(id: string): Promise<void> {
|
export async function deleteParticipant(id: string): Promise<void> {
|
||||||
const db = database();
|
const db = database();
|
||||||
await db.delete(schema.participants).where(eq(schema.participants.id, id));
|
await db.delete(schema.participants).where(eq(schema.participants.id, id));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useFetcher } from "react-router";
|
||||||
import type { Route } from "./+types/admin.data-sync";
|
import type { Route } from "./+types/admin.data-sync";
|
||||||
import { findAllSports } from "~/models/sport";
|
import { findAllSports } from "~/models/sport";
|
||||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
import { findAllSeasonTemplates } from "~/models/season-template";
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
||||||
|
import { countAllParticipants } from "~/models/participant";
|
||||||
|
import { countAllParticipantEVs } from "~/models/participant-expected-value";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,10 +29,13 @@ import {
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
const [sports, sportsSeasons, templates] = await Promise.all([
|
const [sports, sportsSeasons, templates, participantCount, evCount] =
|
||||||
|
await Promise.all([
|
||||||
findAllSports(),
|
findAllSports(),
|
||||||
findAllSportsSeasons(),
|
findAllSportsSeasons(),
|
||||||
findAllSeasonTemplates(),
|
findAllSeasonTemplates(),
|
||||||
|
countAllParticipants(),
|
||||||
|
countAllParticipantEVs(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -37,6 +43,8 @@ export async function loader() {
|
||||||
sportsCount: sports.length,
|
sportsCount: sports.length,
|
||||||
sportsSeasonsCount: sportsSeasons.length,
|
sportsSeasonsCount: sportsSeasons.length,
|
||||||
templatesCount: templates.length,
|
templatesCount: templates.length,
|
||||||
|
participantCount,
|
||||||
|
evCount,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -45,51 +53,55 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
if (intent === "export") {
|
|
||||||
// Export will be handled client-side via download
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (intent === "import") {
|
if (intent === "import") {
|
||||||
const mode = formData.get("mode") as "merge" | "replace";
|
const mode = formData.get("mode") as "merge" | "replace";
|
||||||
const fileData = formData.get("fileData") as string;
|
const fileData = formData.get("fileData") as string;
|
||||||
|
|
||||||
if (!fileData) {
|
if (!fileData) {
|
||||||
return { error: "No file data provided" };
|
return { success: false, error: "No file data provided" };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import the data (we'll create a server-side function for this)
|
const { importSportsDataFromJSON } = await import(
|
||||||
const { importSportsDataFromJSON } = await import("~/utils/sports-data-sync.server");
|
"~/utils/sports-data-sync.server"
|
||||||
|
);
|
||||||
const result = await importSportsDataFromJSON(fileData, mode);
|
const result = await importSportsDataFromJSON(fileData, mode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`
|
error: null,
|
||||||
|
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Import error:", error);
|
console.error("Import error:", error);
|
||||||
return { error: error instanceof Error ? error.message : "Import failed" };
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Import failed",
|
||||||
|
message: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: "Invalid intent" };
|
return { success: false, error: "Invalid intent", message: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataSync({ loaderData, actionData }: Route.ComponentProps) {
|
export default function DataSync({ loaderData }: Route.ComponentProps) {
|
||||||
const { stats } = loaderData;
|
const { stats } = loaderData;
|
||||||
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const fetcher = useFetcher<typeof action>();
|
||||||
|
const isImporting = fetcher.state !== "idle";
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/export-sports-data");
|
const response = await fetch("/api/admin/export-sports-data");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Create download
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
type: "application/json",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -113,7 +125,7 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async (e: React.FormEvent) => {
|
const handleImport = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
|
|
@ -122,38 +134,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async (event) => {
|
reader.onload = (event) => {
|
||||||
const fileData = event.target?.result as string;
|
const fileData = event.target?.result as string;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("intent", "import");
|
formData.append("intent", "import");
|
||||||
formData.append("mode", importMode);
|
formData.append("mode", importMode);
|
||||||
formData.append("fileData", fileData);
|
formData.append("fileData", fileData);
|
||||||
|
fetcher.submit(formData, { method: "POST" });
|
||||||
// Submit via form
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.method = "POST";
|
|
||||||
form.style.display = "none";
|
|
||||||
|
|
||||||
const intentInput = document.createElement("input");
|
|
||||||
intentInput.name = "intent";
|
|
||||||
intentInput.value = "import";
|
|
||||||
form.appendChild(intentInput);
|
|
||||||
|
|
||||||
const modeInput = document.createElement("input");
|
|
||||||
modeInput.name = "mode";
|
|
||||||
modeInput.value = importMode;
|
|
||||||
form.appendChild(modeInput);
|
|
||||||
|
|
||||||
const dataInput = document.createElement("input");
|
|
||||||
dataInput.name = "fileData";
|
|
||||||
dataInput.value = fileData;
|
|
||||||
form.appendChild(dataInput);
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsText(selectedFile);
|
reader.readAsText(selectedFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -167,19 +155,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionData?.success && (
|
{fetcher.data?.success && (
|
||||||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-6">
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-6">
|
||||||
{actionData.message || "Operation completed successfully!"}
|
{fetcher.data.message || "Operation completed successfully!"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actionData?.error && (
|
{fetcher.data?.error && (
|
||||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
||||||
{actionData.error}
|
{fetcher.data.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-3 mb-6">
|
<div className="grid gap-6 md:grid-cols-5 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Sports</CardTitle>
|
<CardTitle className="text-sm font-medium">Sports</CardTitle>
|
||||||
|
|
@ -192,11 +180,39 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Sports Seasons</CardTitle>
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Sports Seasons
|
||||||
|
</CardTitle>
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.sportsSeasonsCount}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Participants
|
||||||
|
</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.participantCount}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">EV Records</CardTitle>
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.evCount}</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -221,7 +237,9 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This will export all sports, sports seasons, participants, participant EV/futures data, and season templates.
|
This will export all sports, sports seasons, participants,
|
||||||
|
participant EV/futures data, participant results, and season
|
||||||
|
templates.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -290,12 +308,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
disabled={!selectedFile}
|
disabled={!selectedFile || isImporting}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
Import Data (Replace)
|
{isImporting
|
||||||
|
? "Importing..."
|
||||||
|
: "Import Data (Replace)"}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
@ -305,15 +325,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
Are you absolutely sure?
|
Are you absolutely sure?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will <strong>delete all existing sports data</strong> and replace it
|
This will{" "}
|
||||||
with the data from the file. This action cannot be undone.
|
<strong>delete all existing sports data</strong> and
|
||||||
<br /><br />
|
replace it with the data from the file. This action
|
||||||
|
cannot be undone.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
This includes:
|
This includes:
|
||||||
<ul className="list-disc list-inside mt-2">
|
<ul className="list-disc list-inside mt-2">
|
||||||
<li>All sports</li>
|
<li>All sports</li>
|
||||||
<li>All sports seasons</li>
|
<li>All sports seasons</li>
|
||||||
<li>All participants</li>
|
<li>All participants</li>
|
||||||
<li>All participant EV/futures data</li>
|
<li>All participant EV/futures data</li>
|
||||||
|
<li>All participant results</li>
|
||||||
<li>All season templates</li>
|
<li>All season templates</li>
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
@ -332,11 +356,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={!selectedFile}
|
disabled={!selectedFile || isImporting}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
Import Data (Merge)
|
{isImporting ? "Importing..." : "Import Data (Merge)"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -349,16 +373,17 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-foreground/80 space-y-3">
|
<CardContent className="text-sm text-foreground/80 space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
|
<strong>Export:</strong> Downloads all sports data as a JSON file
|
||||||
save, version control, or transfer to another environment.
|
that you can save, version control, or transfer to another
|
||||||
|
environment.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
|
<strong>Import (Merge):</strong> Updates existing records and adds
|
||||||
Safe to use - won't delete anything.
|
new ones. Safe to use — won't delete anything.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
|
<strong>Import (Replace):</strong> Deletes all existing sports
|
||||||
it from the file. Use this for a clean slate.
|
data and recreates it from the file. Use this for a clean slate.
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2 border-t border-electric/30">
|
<div className="pt-2 border-t border-electric/30">
|
||||||
<strong>Typical workflow:</strong>
|
<strong>Typical workflow:</strong>
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,170 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
interface ExportData {
|
// ─── Validation schema ────────────────────────────────────────────────────────
|
||||||
version: string;
|
// Used both for runtime validation of imported files and as the canonical shape
|
||||||
exportedAt: string;
|
// for the exported JSON (fixes: zod validation, calculatedAt, participantResults)
|
||||||
sports: Array<{
|
|
||||||
name: string;
|
const ImportDataSchema = z.object({
|
||||||
type: string;
|
version: z.string(),
|
||||||
slug: string;
|
exportedAt: z.string(),
|
||||||
description: string | null;
|
sports: z.array(
|
||||||
}>;
|
z.object({
|
||||||
sportsSeasons: Array<{
|
name: z.string(),
|
||||||
sportSlug: string;
|
type: z.string(),
|
||||||
name: string;
|
slug: z.string(),
|
||||||
year: number;
|
description: z.string().nullable(),
|
||||||
startDate: string | null;
|
})
|
||||||
endDate: string | null;
|
),
|
||||||
status: string;
|
sportsSeasons: z.array(
|
||||||
scoringType: string;
|
z.object({
|
||||||
}>;
|
sportSlug: z.string(),
|
||||||
participants: Array<{
|
name: z.string(),
|
||||||
sportSlug: string;
|
year: z.number(),
|
||||||
seasonName: string;
|
startDate: z.string().nullable(),
|
||||||
seasonYear: number;
|
endDate: z.string().nullable(),
|
||||||
name: string;
|
status: z.string(),
|
||||||
shortName: string | null;
|
scoringType: z.string(),
|
||||||
externalId: string | null;
|
})
|
||||||
expectedValue: number | string;
|
),
|
||||||
}>;
|
participants: z.array(
|
||||||
participantExpectedValues?: Array<{
|
z.object({
|
||||||
sportSlug: string;
|
sportSlug: z.string(),
|
||||||
seasonName: string;
|
seasonName: z.string(),
|
||||||
seasonYear: number;
|
seasonYear: z.number(),
|
||||||
participantName: string;
|
name: z.string(),
|
||||||
probFirst: string;
|
shortName: z.string().nullable(),
|
||||||
probSecond: string;
|
externalId: z.string().nullable(),
|
||||||
probThird: string;
|
expectedValue: z.union([z.number(), z.string()]),
|
||||||
probFourth: string;
|
})
|
||||||
probFifth: string;
|
),
|
||||||
probSixth: string;
|
// Optional for backward compatibility with v1.0 exports
|
||||||
probSeventh: string;
|
participantExpectedValues: z
|
||||||
probEighth: string;
|
.array(
|
||||||
expectedValue: string;
|
z.object({
|
||||||
source: string | null;
|
sportSlug: z.string(),
|
||||||
sourceOdds: number | null;
|
seasonName: z.string(),
|
||||||
}>;
|
seasonYear: z.number(),
|
||||||
seasonTemplates: Array<{
|
participantName: z.string(),
|
||||||
name: string;
|
probFirst: z.string(),
|
||||||
year: number;
|
probSecond: z.string(),
|
||||||
description: string | null;
|
probThird: z.string(),
|
||||||
isActive: boolean;
|
probFourth: z.string(),
|
||||||
sports: Array<{
|
probFifth: z.string(),
|
||||||
sportSlug: string;
|
probSixth: z.string(),
|
||||||
seasonName: string;
|
probSeventh: z.string(),
|
||||||
seasonYear: number;
|
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> {
|
export async function exportSportsDataToJSON(): Promise<ExportData> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Export sports
|
const [
|
||||||
const sports = await db.query.sports.findMany({
|
sports,
|
||||||
orderBy: (sports, { asc }) => [asc(sports.name)],
|
sportsSeasons,
|
||||||
});
|
participants,
|
||||||
|
participantEVs,
|
||||||
// Export sports seasons with sport relation
|
participantResultsData,
|
||||||
const sportsSeasons = await db.query.sportsSeasons.findMany({
|
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.participants.findMany({
|
||||||
|
with: { sportsSeason: { with: { sport: true } } },
|
||||||
|
orderBy: (p, { asc }) => [asc(p.name)],
|
||||||
|
}),
|
||||||
|
db.query.participantExpectedValues.findMany({
|
||||||
with: {
|
with: {
|
||||||
sport: true,
|
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||||
},
|
},
|
||||||
orderBy: (sportsSeasons, { desc, asc }) => [
|
}),
|
||||||
desc(sportsSeasons.year),
|
db.query.participantResults.findMany({
|
||||||
asc(sportsSeasons.name),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export participants with relations
|
|
||||||
const participants = await db.query.participants.findMany({
|
|
||||||
with: {
|
with: {
|
||||||
sportsSeason: {
|
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||||
with: {
|
|
||||||
sport: true,
|
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
db.query.seasonTemplates.findMany({
|
||||||
orderBy: (participants, { asc }) => [asc(participants.name)],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export participant expected values with relations
|
|
||||||
const participantEVs = await db.query.participantExpectedValues.findMany({
|
|
||||||
with: {
|
|
||||||
participant: {
|
|
||||||
with: {
|
|
||||||
sportsSeason: {
|
|
||||||
with: {
|
|
||||||
sport: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export season templates with sports
|
|
||||||
const seasonTemplates = await db.query.seasonTemplates.findMany({
|
|
||||||
with: {
|
with: {
|
||||||
seasonTemplateSports: {
|
seasonTemplateSports: {
|
||||||
with: {
|
with: { sportsSeason: { with: { sport: true } } },
|
||||||
sportsSeason: {
|
|
||||||
with: {
|
|
||||||
sport: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: (t, { desc }) => [desc(t.year)],
|
||||||
},
|
}),
|
||||||
},
|
]);
|
||||||
orderBy: (templates, { desc }) => [desc(templates.year)],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build export data structure
|
|
||||||
return {
|
return {
|
||||||
version: "1.0",
|
version: "1.1",
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
sports: sports.map((sport) => ({
|
sports: sports.map((s) => ({
|
||||||
name: sport.name,
|
name: s.name,
|
||||||
type: sport.type,
|
type: s.type,
|
||||||
slug: sport.slug,
|
slug: s.slug,
|
||||||
description: sport.description,
|
description: s.description,
|
||||||
})),
|
})),
|
||||||
sportsSeasons: sportsSeasons.map((season) => ({
|
sportsSeasons: sportsSeasons.map((s) => ({
|
||||||
sportSlug: season.sport.slug,
|
sportSlug: s.sport.slug,
|
||||||
name: season.name,
|
name: s.name,
|
||||||
year: season.year,
|
year: s.year,
|
||||||
startDate: season.startDate,
|
startDate: s.startDate,
|
||||||
endDate: season.endDate,
|
endDate: s.endDate,
|
||||||
status: season.status,
|
status: s.status,
|
||||||
scoringType: season.scoringType,
|
scoringType: s.scoringType,
|
||||||
})),
|
})),
|
||||||
participants: participants.map((participant) => ({
|
participants: participants.map((p) => ({
|
||||||
sportSlug: participant.sportsSeason.sport.slug,
|
sportSlug: p.sportsSeason.sport.slug,
|
||||||
seasonName: participant.sportsSeason.name,
|
seasonName: p.sportsSeason.name,
|
||||||
seasonYear: participant.sportsSeason.year,
|
seasonYear: p.sportsSeason.year,
|
||||||
name: participant.name,
|
name: p.name,
|
||||||
shortName: participant.shortName,
|
shortName: p.shortName,
|
||||||
externalId: participant.externalId,
|
externalId: p.externalId,
|
||||||
expectedValue: participant.expectedValue,
|
expectedValue: p.expectedValue,
|
||||||
})),
|
})),
|
||||||
participantExpectedValues: participantEVs.map((ev) => ({
|
participantExpectedValues: participantEVs.map((ev) => ({
|
||||||
sportSlug: ev.participant.sportsSeason.sport.slug,
|
sportSlug: ev.participant.sportsSeason.sport.slug,
|
||||||
|
|
@ -165,13 +182,23 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
||||||
expectedValue: ev.expectedValue,
|
expectedValue: ev.expectedValue,
|
||||||
source: ev.source,
|
source: ev.source,
|
||||||
sourceOdds: ev.sourceOdds,
|
sourceOdds: ev.sourceOdds,
|
||||||
|
calculatedAt: ev.calculatedAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
seasonTemplates: seasonTemplates.map((template) => ({
|
participantResults: participantResultsData.map((r) => ({
|
||||||
name: template.name,
|
sportSlug: r.participant.sportsSeason.sport.slug,
|
||||||
year: template.year,
|
seasonName: r.participant.sportsSeason.name,
|
||||||
description: template.description,
|
seasonYear: r.participant.sportsSeason.year,
|
||||||
isActive: template.isActive,
|
participantName: r.participant.name,
|
||||||
sports: template.seasonTemplateSports.map((ts) => ({
|
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,
|
sportSlug: ts.sportsSeason.sport.slug,
|
||||||
seasonName: ts.sportsSeason.name,
|
seasonName: ts.sportsSeason.name,
|
||||||
seasonYear: ts.sportsSeason.year,
|
seasonYear: ts.sportsSeason.year,
|
||||||
|
|
@ -180,41 +207,60 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Import ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function importSportsDataFromJSON(
|
export async function importSportsDataFromJSON(
|
||||||
jsonData: string,
|
jsonData: string,
|
||||||
mode: "merge" | "replace" = "merge"
|
mode: "merge" | "replace" = "merge"
|
||||||
): Promise<{ created: number; updated: number; skipped: number }> {
|
): Promise<{ created: number; updated: number; skipped: number }> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const importData: ExportData = JSON.parse(jsonData);
|
|
||||||
|
|
||||||
|
// 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"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap everything in a transaction so a mid-import failure rolls back cleanly
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
if (mode === "replace") {
|
if (mode === "replace") {
|
||||||
// Delete in reverse order of dependencies
|
// Delete in reverse dependency order.
|
||||||
await db.delete(schema.seasonTemplateSports);
|
// participantExpectedValues and participantResults both carry
|
||||||
await db.delete(schema.seasonTemplates);
|
// onDelete: "cascade" on their participantId FK, so they are
|
||||||
await db.delete(schema.participantResults);
|
// automatically removed when participants is deleted.
|
||||||
await db.delete(schema.participants);
|
await tx.delete(schema.seasonTemplateSports);
|
||||||
await db.delete(schema.seasonSports);
|
await tx.delete(schema.seasonTemplates);
|
||||||
await db.delete(schema.sportsSeasons);
|
await tx.delete(schema.participants); // cascades to EVs + results
|
||||||
await db.delete(schema.sports);
|
await tx.delete(schema.seasonSports);
|
||||||
|
await tx.delete(schema.sportsSeasons);
|
||||||
|
await tx.delete(schema.sports);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track created IDs for relationships
|
// ID maps used to resolve relationships without further queries
|
||||||
const sportIdMap = new Map<string, string>();
|
const sportIdMap = new Map<string, string>();
|
||||||
const sportsSeasonIdMap = 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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Import sports
|
|
||||||
for (const sport of importData.sports) {
|
for (const sport of importData.sports) {
|
||||||
const existing = await db.query.sports.findFirst({
|
const existing = await tx.query.sports.findFirst({
|
||||||
where: eq(schema.sports.slug, sport.slug),
|
where: eq(schema.sports.slug, sport.slug),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (mode === "merge") {
|
if (mode === "merge") {
|
||||||
await db
|
await tx
|
||||||
.update(schema.sports)
|
.update(schema.sports)
|
||||||
.set({
|
.set({
|
||||||
name: sport.name,
|
name: sport.name,
|
||||||
|
|
@ -226,7 +272,7 @@ export async function importSportsDataFromJSON(
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [createdSport] = await db
|
const [created_] = await tx
|
||||||
.insert(schema.sports)
|
.insert(schema.sports)
|
||||||
.values({
|
.values({
|
||||||
name: sport.name,
|
name: sport.name,
|
||||||
|
|
@ -235,12 +281,13 @@ export async function importSportsDataFromJSON(
|
||||||
description: sport.description,
|
description: sport.description,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
sportIdMap.set(sport.slug, createdSport.id);
|
sportIdMap.set(sport.slug, created_.id);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import sports seasons
|
// ── Sports seasons ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
for (const season of importData.sportsSeasons) {
|
for (const season of importData.sportsSeasons) {
|
||||||
const sportId = sportIdMap.get(season.sportSlug);
|
const sportId = sportIdMap.get(season.sportSlug);
|
||||||
if (!sportId) {
|
if (!sportId) {
|
||||||
|
|
@ -248,7 +295,9 @@ export async function importSportsDataFromJSON(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.sportsSeasons.findFirst({
|
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
|
||||||
|
|
||||||
|
const existing = await tx.query.sportsSeasons.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.sportsSeasons.sportId, sportId),
|
eq(schema.sportsSeasons.sportId, sportId),
|
||||||
eq(schema.sportsSeasons.name, season.name),
|
eq(schema.sportsSeasons.name, season.name),
|
||||||
|
|
@ -256,24 +305,25 @@ export async function importSportsDataFromJSON(
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (mode === "merge") {
|
if (mode === "merge") {
|
||||||
await db
|
await tx
|
||||||
.update(schema.sportsSeasons)
|
.update(schema.sportsSeasons)
|
||||||
.set({
|
.set({
|
||||||
startDate: season.startDate,
|
startDate: season.startDate,
|
||||||
endDate: season.endDate,
|
endDate: season.endDate,
|
||||||
status: season.status as "upcoming" | "active" | "completed",
|
status: season.status as "upcoming" | "active" | "completed",
|
||||||
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
|
scoringType: season.scoringType as
|
||||||
|
| "playoffs"
|
||||||
|
| "regular_season"
|
||||||
|
| "majors",
|
||||||
})
|
})
|
||||||
.where(eq(schema.sportsSeasons.id, existing.id));
|
.where(eq(schema.sportsSeasons.id, existing.id));
|
||||||
sportsSeasonIdMap.set(seasonKey, existing.id);
|
sportsSeasonIdMap.set(seasonKey, existing.id);
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [createdSeason] = await db
|
const [created_] = await tx
|
||||||
.insert(schema.sportsSeasons)
|
.insert(schema.sportsSeasons)
|
||||||
.values({
|
.values({
|
||||||
sportId,
|
sportId,
|
||||||
|
|
@ -282,71 +332,85 @@ export async function importSportsDataFromJSON(
|
||||||
startDate: season.startDate,
|
startDate: season.startDate,
|
||||||
endDate: season.endDate,
|
endDate: season.endDate,
|
||||||
status: season.status as "upcoming" | "active" | "completed",
|
status: season.status as "upcoming" | "active" | "completed",
|
||||||
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
|
scoringType: season.scoringType as
|
||||||
|
| "playoffs"
|
||||||
|
| "regular_season"
|
||||||
|
| "majors",
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
sportsSeasonIdMap.set(seasonKey, createdSeason.id);
|
sportsSeasonIdMap.set(seasonKey, created_.id);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import participants
|
// ── Participants ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
for (const participant of importData.participants) {
|
for (const participant of importData.participants) {
|
||||||
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
|
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
|
||||||
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
||||||
|
|
||||||
if (!sportsSeasonId) {
|
if (!sportsSeasonId) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.participants.findFirst({
|
const existing = await tx.query.participants.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||||
eq(schema.participants.name, participant.name)
|
eq(schema.participants.name, participant.name)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (existing) {
|
||||||
await db.insert(schema.participants).values({
|
// Update all mutable fields (fixes: merge mode was silently skipping)
|
||||||
|
await tx
|
||||||
|
.update(schema.participants)
|
||||||
|
.set({
|
||||||
|
shortName: participant.shortName,
|
||||||
|
externalId: participant.externalId,
|
||||||
|
expectedValue: String(participant.expectedValue ?? 0),
|
||||||
|
})
|
||||||
|
.where(eq(schema.participants.id, existing.id));
|
||||||
|
participantIdMap.set(`${sportsSeasonId}:${participant.name}`, existing.id);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
const [created_] = await tx
|
||||||
|
.insert(schema.participants)
|
||||||
|
.values({
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
name: participant.name,
|
name: participant.name,
|
||||||
shortName: participant.shortName,
|
shortName: participant.shortName,
|
||||||
externalId: participant.externalId,
|
externalId: participant.externalId,
|
||||||
expectedValue: String(participant.expectedValue ?? 0),
|
expectedValue: String(participant.expectedValue ?? 0),
|
||||||
});
|
})
|
||||||
|
.returning();
|
||||||
|
participantIdMap.set(`${sportsSeasonId}:${participant.name}`, created_.id);
|
||||||
created++;
|
created++;
|
||||||
} else {
|
|
||||||
skipped++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import participant expected values (optional field for backward compatibility)
|
// ── Participant expected values ──────────────────────────────────────────
|
||||||
|
|
||||||
if (importData.participantExpectedValues) {
|
if (importData.participantExpectedValues) {
|
||||||
for (const ev of importData.participantExpectedValues) {
|
for (const ev of importData.participantExpectedValues) {
|
||||||
const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`;
|
const seasonKey = `${ev.sportSlug}:${ev.seasonName}:${ev.seasonYear}`;
|
||||||
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
||||||
|
|
||||||
if (!sportsSeasonId) {
|
if (!sportsSeasonId) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const participant = await db.query.participants.findFirst({
|
// Use map built during participant import — no extra query needed
|
||||||
where: and(
|
const participantId = participantIdMap.get(
|
||||||
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
`${sportsSeasonId}:${ev.participantName}`
|
||||||
eq(schema.participants.name, ev.participantName)
|
);
|
||||||
),
|
if (!participantId) {
|
||||||
});
|
|
||||||
|
|
||||||
if (!participant) {
|
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingEV = await db.query.participantExpectedValues.findFirst({
|
const existingEV = await tx.query.participantExpectedValues.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.participantExpectedValues.participantId, participant.id),
|
eq(schema.participantExpectedValues.participantId, participantId),
|
||||||
eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
@ -361,40 +425,101 @@ export async function importSportsDataFromJSON(
|
||||||
probSeventh: ev.probSeventh,
|
probSeventh: ev.probSeventh,
|
||||||
probEighth: ev.probEighth,
|
probEighth: ev.probEighth,
|
||||||
expectedValue: ev.expectedValue,
|
expectedValue: ev.expectedValue,
|
||||||
source: ev.source as "manual" | "futures_odds" | "elo_simulation" | "performance_model" | null,
|
source: ev.source as
|
||||||
|
| "manual"
|
||||||
|
| "futures_odds"
|
||||||
|
| "elo_simulation"
|
||||||
|
| "performance_model"
|
||||||
|
| null,
|
||||||
sourceOdds: ev.sourceOdds,
|
sourceOdds: ev.sourceOdds,
|
||||||
|
// Restore original timestamp when present (fixes: calculatedAt lost)
|
||||||
|
...(ev.calculatedAt
|
||||||
|
? { calculatedAt: new Date(ev.calculatedAt) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingEV) {
|
if (existingEV) {
|
||||||
if (mode === "merge") {
|
if (mode === "merge") {
|
||||||
await db
|
await tx
|
||||||
.update(schema.participantExpectedValues)
|
.update(schema.participantExpectedValues)
|
||||||
.set({ ...evValues, updatedAt: new Date() })
|
.set({ ...evValues, updatedAt: new Date() })
|
||||||
.where(eq(schema.participantExpectedValues.id, existingEV.id));
|
.where(eq(schema.participantExpectedValues.id, existingEV.id));
|
||||||
await db
|
await tx
|
||||||
.update(schema.participants)
|
.update(schema.participants)
|
||||||
.set({ expectedValue: ev.expectedValue })
|
.set({ expectedValue: ev.expectedValue })
|
||||||
.where(eq(schema.participants.id, participant.id));
|
.where(eq(schema.participants.id, participantId));
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await db.insert(schema.participantExpectedValues).values({
|
await tx.insert(schema.participantExpectedValues).values({
|
||||||
participantId: participant.id,
|
participantId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
...evValues,
|
...evValues,
|
||||||
});
|
});
|
||||||
await db
|
await tx
|
||||||
.update(schema.participants)
|
.update(schema.participants)
|
||||||
.set({ expectedValue: ev.expectedValue })
|
.set({ expectedValue: ev.expectedValue })
|
||||||
.where(eq(schema.participants.id, participant.id));
|
.where(eq(schema.participants.id, participantId));
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import season templates
|
// ── 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.participantResults.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.participantResults.participantId, participantId),
|
||||||
|
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultValues = {
|
||||||
|
finalPosition: result.finalPosition,
|
||||||
|
qualifyingPoints: result.qualifyingPoints,
|
||||||
|
notes: result.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingResult) {
|
||||||
|
if (mode === "merge") {
|
||||||
|
await tx
|
||||||
|
.update(schema.participantResults)
|
||||||
|
.set({ ...resultValues, updatedAt: new Date() })
|
||||||
|
.where(eq(schema.participantResults.id, existingResult.id));
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await tx.insert(schema.participantResults).values({
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId,
|
||||||
|
...resultValues,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Season templates ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
for (const template of importData.seasonTemplates) {
|
for (const template of importData.seasonTemplates) {
|
||||||
const existing = await db.query.seasonTemplates.findFirst({
|
const existing = await tx.query.seasonTemplates.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.seasonTemplates.name, template.name),
|
eq(schema.seasonTemplates.name, template.name),
|
||||||
eq(schema.seasonTemplates.year, template.year)
|
eq(schema.seasonTemplates.year, template.year)
|
||||||
|
|
@ -405,7 +530,7 @@ export async function importSportsDataFromJSON(
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (mode === "merge") {
|
if (mode === "merge") {
|
||||||
await db
|
await tx
|
||||||
.update(schema.seasonTemplates)
|
.update(schema.seasonTemplates)
|
||||||
.set({
|
.set({
|
||||||
description: template.description,
|
description: template.description,
|
||||||
|
|
@ -415,15 +540,15 @@ export async function importSportsDataFromJSON(
|
||||||
templateId = existing.id;
|
templateId = existing.id;
|
||||||
updated++;
|
updated++;
|
||||||
|
|
||||||
// Remove existing template sports to re-add them
|
// Remove existing template sports to re-add them cleanly
|
||||||
await db
|
await tx
|
||||||
.delete(schema.seasonTemplateSports)
|
.delete(schema.seasonTemplateSports)
|
||||||
.where(eq(schema.seasonTemplateSports.templateId, templateId));
|
.where(eq(schema.seasonTemplateSports.templateId, templateId));
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [createdTemplate] = await db
|
const [created_] = await tx
|
||||||
.insert(schema.seasonTemplates)
|
.insert(schema.seasonTemplates)
|
||||||
.values({
|
.values({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
|
|
@ -432,17 +557,15 @@ export async function importSportsDataFromJSON(
|
||||||
isActive: template.isActive,
|
isActive: template.isActive,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
templateId = createdTemplate.id;
|
templateId = created_.id;
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add template sports
|
|
||||||
for (const templateSport of template.sports) {
|
for (const templateSport of template.sports) {
|
||||||
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
|
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
|
||||||
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
||||||
|
|
||||||
if (sportsSeasonId) {
|
if (sportsSeasonId) {
|
||||||
await db.insert(schema.seasonTemplateSports).values({
|
await tx.insert(schema.seasonTemplateSports).values({
|
||||||
templateId,
|
templateId,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
});
|
});
|
||||||
|
|
@ -451,4 +574,5 @@ export async function importSportsDataFromJSON(
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, updated, skipped };
|
return { created, updated, skipped };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -48,7 +48,8 @@
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svix": "^1.77.0",
|
"svix": "^1.77.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.7.1",
|
"@react-router/dev": "^7.7.1",
|
||||||
|
|
@ -2457,6 +2458,16 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mswjs/interceptors": {
|
"node_modules/@mswjs/interceptors": {
|
||||||
"version": "0.40.0",
|
"version": "0.40.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
|
||||||
|
|
@ -12144,6 +12155,16 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shadcn/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -14090,23 +14111,22 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.24.6",
|
"version": "3.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
|
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.25 || ^4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"svix": "^1.77.0",
|
"svix": "^1.77.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.7.1",
|
"@react-router/dev": "^7.7.1",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue