Add Zod validation and expand export/import for EV and results data (#14)
* Include participant EV/futures data in data sync export/import The export now includes the full participantExpectedValues records (placement probabilities, source, sourceOdds) alongside participants. On import, EV records are upserted in merge mode and created fresh in replace mode (cascade handles cleanup). Backward-compatible with older exports that lack the participantExpectedValues field. https://claude.ai/code/session_01TVzuuDR7jPtmihaXTrsX7B * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
401efcd833
commit
9ae00a8385
6 changed files with 659 additions and 356 deletions
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { database } from "~/database/context";
|
||||
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 { 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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, count } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -73,6 +73,12 @@ export async function updateParticipant(
|
|||
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> {
|
||||
const db = database();
|
||||
await db.delete(schema.participants).where(eq(schema.participants.id, id));
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
import type { Route } from "./+types/admin.data-sync";
|
||||
import { findAllSports } from "~/models/sport";
|
||||
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||
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 { Label } from "~/components/ui/label";
|
||||
import {
|
||||
|
|
@ -26,17 +29,22 @@ import {
|
|||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
export async function loader() {
|
||||
const [sports, sportsSeasons, templates] = await Promise.all([
|
||||
findAllSports(),
|
||||
findAllSportsSeasons(),
|
||||
findAllSeasonTemplates(),
|
||||
]);
|
||||
const [sports, sportsSeasons, templates, participantCount, evCount] =
|
||||
await Promise.all([
|
||||
findAllSports(),
|
||||
findAllSportsSeasons(),
|
||||
findAllSeasonTemplates(),
|
||||
countAllParticipants(),
|
||||
countAllParticipantEVs(),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
sportsCount: sports.length,
|
||||
sportsSeasonsCount: sportsSeasons.length,
|
||||
templatesCount: templates.length,
|
||||
participantCount,
|
||||
evCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -45,51 +53,55 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "export") {
|
||||
// Export will be handled client-side via download
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (intent === "import") {
|
||||
const mode = formData.get("mode") as "merge" | "replace";
|
||||
const fileData = formData.get("fileData") as string;
|
||||
|
||||
if (!fileData) {
|
||||
return { error: "No file data provided" };
|
||||
return { success: false, error: "No file data provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Import the data (we'll create a server-side function for this)
|
||||
const { importSportsDataFromJSON } = await import("~/utils/sports-data-sync.server");
|
||||
const { importSportsDataFromJSON } = await import(
|
||||
"~/utils/sports-data-sync.server"
|
||||
);
|
||||
const result = await importSportsDataFromJSON(fileData, mode);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`
|
||||
return {
|
||||
success: true,
|
||||
error: null,
|
||||
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`,
|
||||
};
|
||||
} catch (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 [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const isImporting = fetcher.state !== "idle";
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/export-sports-data");
|
||||
const data = await response.json();
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
|
@ -113,47 +125,23 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.FormEvent) => {
|
||||
const handleImport = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!selectedFile) {
|
||||
alert("Please select a file first");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
reader.onload = (event) => {
|
||||
const fileData = event.target?.result as string;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("intent", "import");
|
||||
formData.append("mode", importMode);
|
||||
formData.append("fileData", fileData);
|
||||
|
||||
// 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();
|
||||
fetcher.submit(formData, { method: "POST" });
|
||||
};
|
||||
|
||||
reader.readAsText(selectedFile);
|
||||
};
|
||||
|
||||
|
|
@ -167,19 +155,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
</p>
|
||||
</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">
|
||||
{actionData.message || "Operation completed successfully!"}
|
||||
{fetcher.data.message || "Operation completed successfully!"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
{fetcher.data?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
||||
{actionData.error}
|
||||
{fetcher.data.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3 mb-6">
|
||||
<div className="grid gap-6 md:grid-cols-5 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Sports</CardTitle>
|
||||
|
|
@ -192,11 +180,39 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
|
||||
<Card>
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
|
|
@ -221,9 +237,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will export all sports, sports seasons, participants, and season templates.
|
||||
This will export all sports, sports seasons, participants,
|
||||
participant EV/futures data, participant results, and season
|
||||
templates.
|
||||
</p>
|
||||
|
||||
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
|
|
@ -290,12 +308,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
disabled={!selectedFile}
|
||||
disabled={!selectedFile || isImporting}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Data (Replace)
|
||||
{isImporting
|
||||
? "Importing..."
|
||||
: "Import Data (Replace)"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
|
|
@ -305,14 +325,19 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will <strong>delete all existing sports data</strong> and replace it
|
||||
with the data from the file. This action cannot be undone.
|
||||
<br /><br />
|
||||
This will{" "}
|
||||
<strong>delete all existing sports data</strong> and
|
||||
replace it with the data from the file. This action
|
||||
cannot be undone.
|
||||
<br />
|
||||
<br />
|
||||
This includes:
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>All sports</li>
|
||||
<li>All sports seasons</li>
|
||||
<li>All participants</li>
|
||||
<li>All participant EV/futures data</li>
|
||||
<li>All participant results</li>
|
||||
<li>All season templates</li>
|
||||
</ul>
|
||||
</AlertDialogDescription>
|
||||
|
|
@ -331,11 +356,11 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
) : (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedFile}
|
||||
disabled={!selectedFile || isImporting}
|
||||
className="w-full"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import Data (Merge)
|
||||
{isImporting ? "Importing..." : "Import Data (Merge)"}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -348,16 +373,17 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
</CardHeader>
|
||||
<CardContent className="text-sm text-foreground/80 space-y-3">
|
||||
<div>
|
||||
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
|
||||
save, version control, or transfer to another environment.
|
||||
<strong>Export:</strong> Downloads all sports data as a JSON file
|
||||
that you can save, version control, or transfer to another
|
||||
environment.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
|
||||
Safe to use - won't delete anything.
|
||||
<strong>Import (Merge):</strong> Updates existing records and adds
|
||||
new ones. Safe to use — won't delete anything.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
|
||||
it from the file. Use this for a clean slate.
|
||||
<strong>Import (Replace):</strong> Deletes all existing sports
|
||||
data and recreates it from the file. Use this for a clean slate.
|
||||
</div>
|
||||
<div className="pt-2 border-t border-electric/30">
|
||||
<strong>Typical workflow:</strong>
|
||||
|
|
|
|||
|
|
@ -1,128 +1,204 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
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;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
// ─── Validation schema ────────────────────────────────────────────────────────
|
||||
// Used both for runtime validation of imported files and as the canonical shape
|
||||
// for the exported JSON (fixes: zod validation, calculatedAt, participantResults)
|
||||
|
||||
const ImportDataSchema = z.object({
|
||||
version: z.string(),
|
||||
exportedAt: z.string(),
|
||||
sports: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
),
|
||||
sportsSeasons: z.array(
|
||||
z.object({
|
||||
sportSlug: z.string(),
|
||||
name: z.string(),
|
||||
year: z.number(),
|
||||
startDate: z.string().nullable(),
|
||||
endDate: z.string().nullable(),
|
||||
status: z.string(),
|
||||
scoringType: z.string(),
|
||||
})
|
||||
),
|
||||
participants: z.array(
|
||||
z.object({
|
||||
sportSlug: z.string(),
|
||||
seasonName: z.string(),
|
||||
seasonYear: z.number(),
|
||||
name: z.string(),
|
||||
shortName: z.string().nullable(),
|
||||
externalId: z.string().nullable(),
|
||||
expectedValue: z.union([z.number(), z.string()]),
|
||||
})
|
||||
),
|
||||
// Optional for backward compatibility with v1.0 exports
|
||||
participantExpectedValues: z
|
||||
.array(
|
||||
z.object({
|
||||
sportSlug: z.string(),
|
||||
seasonName: z.string(),
|
||||
seasonYear: z.number(),
|
||||
participantName: z.string(),
|
||||
probFirst: z.string(),
|
||||
probSecond: z.string(),
|
||||
probThird: z.string(),
|
||||
probFourth: z.string(),
|
||||
probFifth: z.string(),
|
||||
probSixth: z.string(),
|
||||
probSeventh: z.string(),
|
||||
probEighth: z.string(),
|
||||
expectedValue: z.string(),
|
||||
source: z.string().nullable(),
|
||||
sourceOdds: z.number().nullable(),
|
||||
calculatedAt: z.string().optional(), // optional for backward compat
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
participantResults: z
|
||||
.array(
|
||||
z.object({
|
||||
sportSlug: z.string(),
|
||||
seasonName: z.string(),
|
||||
seasonYear: z.number(),
|
||||
participantName: z.string(),
|
||||
finalPosition: z.number().nullable(),
|
||||
qualifyingPoints: z.string().nullable(),
|
||||
notes: z.string().nullable(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
seasonTemplates: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
year: z.number(),
|
||||
description: z.string().nullable(),
|
||||
isActive: z.boolean(),
|
||||
sports: z.array(
|
||||
z.object({
|
||||
sportSlug: z.string(),
|
||||
seasonName: z.string(),
|
||||
seasonYear: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type ExportData = z.infer<typeof ImportDataSchema>;
|
||||
|
||||
// ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
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.participants.findMany({
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
orderBy: (p, { asc }) => [asc(p.name)],
|
||||
}),
|
||||
db.query.participantExpectedValues.findMany({
|
||||
with: {
|
||||
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||
},
|
||||
}),
|
||||
db.query.participantResults.findMany({
|
||||
with: {
|
||||
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||
},
|
||||
}),
|
||||
db.query.seasonTemplates.findMany({
|
||||
with: {
|
||||
seasonTemplateSports: {
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (participants, { asc }) => [asc(participants.name)],
|
||||
});
|
||||
orderBy: (t, { desc }) => [desc(t.year)],
|
||||
}),
|
||||
]);
|
||||
|
||||
// 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",
|
||||
version: "1.1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
sports: sports.map((sport) => ({
|
||||
name: sport.name,
|
||||
type: sport.type,
|
||||
slug: sport.slug,
|
||||
description: sport.description,
|
||||
sports: sports.map((s) => ({
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
slug: s.slug,
|
||||
description: s.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,
|
||||
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((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,
|
||||
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,
|
||||
})),
|
||||
seasonTemplates: seasonTemplates.map((template) => ({
|
||||
name: template.name,
|
||||
year: template.year,
|
||||
description: template.description,
|
||||
isActive: template.isActive,
|
||||
sports: template.seasonTemplateSports.map((ts) => ({
|
||||
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,
|
||||
|
|
@ -131,204 +207,372 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
|||
};
|
||||
}
|
||||
|
||||
// ─── Import ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
// 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"}`
|
||||
);
|
||||
}
|
||||
|
||||
// Track created IDs for relationships
|
||||
const sportIdMap = new Map<string, string>();
|
||||
const sportsSeasonIdMap = new Map<string, string>();
|
||||
// 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;
|
||||
|
||||
// Import sports
|
||||
for (const sport of importData.sports) {
|
||||
const existing = await db.query.sports.findFirst({
|
||||
where: eq(schema.sports.slug, sport.slug),
|
||||
});
|
||||
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.participants); // cascades to EVs + results
|
||||
await tx.delete(schema.seasonSports);
|
||||
await tx.delete(schema.sportsSeasons);
|
||||
await tx.delete(schema.sports);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (mode === "merge") {
|
||||
await db
|
||||
.update(schema.sports)
|
||||
.set({
|
||||
// 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,
|
||||
})
|
||||
.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,
|
||||
})
|
||||
.where(eq(schema.sports.slug, sport.slug));
|
||||
sportIdMap.set(sport.slug, existing.id);
|
||||
updated++;
|
||||
.returning();
|
||||
sportIdMap.set(sport.slug, created_.id);
|
||||
created++;
|
||||
}
|
||||
} 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)
|
||||
),
|
||||
});
|
||||
// ── Sports seasons ───────────────────────────────────────────────────────
|
||||
|
||||
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
|
||||
for (const season of importData.sportsSeasons) {
|
||||
const sportId = sportIdMap.get(season.sportSlug);
|
||||
if (!sportId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (mode === "merge") {
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({
|
||||
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",
|
||||
scoringType: season.scoringType as
|
||||
| "playoffs"
|
||||
| "regular_season"
|
||||
| "majors",
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, existing.id));
|
||||
sportsSeasonIdMap.set(seasonKey, existing.id);
|
||||
updated++;
|
||||
.returning();
|
||||
sportsSeasonIdMap.set(seasonKey, created_.id);
|
||||
created++;
|
||||
}
|
||||
} 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)
|
||||
),
|
||||
});
|
||||
// ── Participants ─────────────────────────────────────────────────────────
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(schema.participants).values({
|
||||
sportsSeasonId,
|
||||
name: participant.name,
|
||||
shortName: participant.shortName,
|
||||
externalId: participant.externalId,
|
||||
expectedValue: String(participant.expectedValue ?? 0),
|
||||
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.participants.findFirst({
|
||||
where: and(
|
||||
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.participants.name, participant.name)
|
||||
),
|
||||
});
|
||||
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)
|
||||
if (existing) {
|
||||
// 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,
|
||||
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.participantExpectedValues.findFirst({
|
||||
where: and(
|
||||
eq(schema.participantExpectedValues.participantId, participantId),
|
||||
eq(schema.participantExpectedValues.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.participantExpectedValues)
|
||||
.set({ ...evValues, updatedAt: new Date() })
|
||||
.where(eq(schema.participantExpectedValues.id, existingEV.id));
|
||||
await tx
|
||||
.update(schema.participants)
|
||||
.set({ expectedValue: ev.expectedValue })
|
||||
.where(eq(schema.participants.id, participantId));
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
await tx.insert(schema.participantExpectedValues).values({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
...evValues,
|
||||
});
|
||||
await tx
|
||||
.update(schema.participants)
|
||||
.set({ expectedValue: ev.expectedValue })
|
||||
.where(eq(schema.participants.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.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) {
|
||||
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,
|
||||
})
|
||||
.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;
|
||||
.returning();
|
||||
templateId = created_.id;
|
||||
created++;
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
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 };
|
||||
return { created, updated, skipped };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -48,7 +48,8 @@
|
|||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.77.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.7.1",
|
||||
|
|
@ -2457,6 +2458,16 @@
|
|||
"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": {
|
||||
"version": "0.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz",
|
||||
|
|
@ -12144,6 +12155,16 @@
|
|||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
|
@ -14090,23 +14111,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
|
||||
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@
|
|||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.77.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.7.1",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue