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 { 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,10 +29,13 @@ import {
|
|||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
export async function loader() {
|
||||
const [sports, sportsSeasons, templates] = await Promise.all([
|
||||
const [sports, sportsSeasons, templates, participantCount, evCount] =
|
||||
await Promise.all([
|
||||
findAllSports(),
|
||||
findAllSportsSeasons(),
|
||||
findAllSeasonTemplates(),
|
||||
countAllParticipants(),
|
||||
countAllParticipantEVs(),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -37,6 +43,8 @@ export async function loader() {
|
|||
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}`
|
||||
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,7 +125,7 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.FormEvent) => {
|
||||
const handleImport = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFile) {
|
||||
|
|
@ -122,38 +134,14 @@ export default function DataSync({ loaderData, actionData }: Route.ComponentProp
|
|||
}
|
||||
|
||||
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,7 +237,9 @@ 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, 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>
|
||||
|
||||
<Button
|
||||
|
|
@ -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,15 +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>
|
||||
|
|
@ -332,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>
|
||||
|
|
@ -349,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,153 +1,170 @@
|
|||
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;
|
||||
}>;
|
||||
participantExpectedValues?: Array<{
|
||||
sportSlug: string;
|
||||
seasonName: string;
|
||||
seasonYear: number;
|
||||
participantName: string;
|
||||
probFirst: string;
|
||||
probSecond: string;
|
||||
probThird: string;
|
||||
probFourth: string;
|
||||
probFifth: string;
|
||||
probSixth: string;
|
||||
probSeventh: string;
|
||||
probEighth: string;
|
||||
expectedValue: string;
|
||||
source: string | null;
|
||||
sourceOdds: number | null;
|
||||
}>;
|
||||
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({
|
||||
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: {
|
||||
sport: true,
|
||||
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||
},
|
||||
orderBy: (sportsSeasons, { desc, asc }) => [
|
||||
desc(sportsSeasons.year),
|
||||
asc(sportsSeasons.name),
|
||||
],
|
||||
});
|
||||
|
||||
// Export participants with relations
|
||||
const participants = await db.query.participants.findMany({
|
||||
}),
|
||||
db.query.participantResults.findMany({
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
participant: { with: { sportsSeason: { with: { sport: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
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({
|
||||
}),
|
||||
db.query.seasonTemplates.findMany({
|
||||
with: {
|
||||
seasonTemplateSports: {
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
with: { sportsSeason: { with: { sport: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: (templates, { desc }) => [desc(templates.year)],
|
||||
});
|
||||
orderBy: (t, { desc }) => [desc(t.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,
|
||||
})),
|
||||
participantExpectedValues: participantEVs.map((ev) => ({
|
||||
sportSlug: ev.participant.sportsSeason.sport.slug,
|
||||
|
|
@ -165,13 +182,23 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
|
|||
expectedValue: ev.expectedValue,
|
||||
source: ev.source,
|
||||
sourceOdds: ev.sourceOdds,
|
||||
calculatedAt: ev.calculatedAt.toISOString(),
|
||||
})),
|
||||
seasonTemplates: seasonTemplates.map((template) => ({
|
||||
name: template.name,
|
||||
year: template.year,
|
||||
description: template.description,
|
||||
isActive: template.isActive,
|
||||
sports: template.seasonTemplateSports.map((ts) => ({
|
||||
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,
|
||||
|
|
@ -180,41 +207,60 @@ 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);
|
||||
|
||||
// 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 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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Track created IDs for relationships
|
||||
// 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Import 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),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (mode === "merge") {
|
||||
await db
|
||||
await tx
|
||||
.update(schema.sports)
|
||||
.set({
|
||||
name: sport.name,
|
||||
|
|
@ -226,7 +272,7 @@ export async function importSportsDataFromJSON(
|
|||
updated++;
|
||||
}
|
||||
} else {
|
||||
const [createdSport] = await db
|
||||
const [created_] = await tx
|
||||
.insert(schema.sports)
|
||||
.values({
|
||||
name: sport.name,
|
||||
|
|
@ -235,12 +281,13 @@ export async function importSportsDataFromJSON(
|
|||
description: sport.description,
|
||||
})
|
||||
.returning();
|
||||
sportIdMap.set(sport.slug, createdSport.id);
|
||||
sportIdMap.set(sport.slug, created_.id);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Import sports seasons
|
||||
// ── Sports seasons ───────────────────────────────────────────────────────
|
||||
|
||||
for (const season of importData.sportsSeasons) {
|
||||
const sportId = sportIdMap.get(season.sportSlug);
|
||||
if (!sportId) {
|
||||
|
|
@ -248,7 +295,9 @@ export async function importSportsDataFromJSON(
|
|||
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(
|
||||
eq(schema.sportsSeasons.sportId, sportId),
|
||||
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 (mode === "merge") {
|
||||
await db
|
||||
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",
|
||||
scoringType: season.scoringType as
|
||||
| "playoffs"
|
||||
| "regular_season"
|
||||
| "majors",
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, existing.id));
|
||||
sportsSeasonIdMap.set(seasonKey, existing.id);
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
const [createdSeason] = await db
|
||||
const [created_] = await tx
|
||||
.insert(schema.sportsSeasons)
|
||||
.values({
|
||||
sportId,
|
||||
|
|
@ -282,71 +332,85 @@ export async function importSportsDataFromJSON(
|
|||
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",
|
||||
})
|
||||
.returning();
|
||||
sportsSeasonIdMap.set(seasonKey, createdSeason.id);
|
||||
sportsSeasonIdMap.set(seasonKey, created_.id);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Import participants
|
||||
// ── 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({
|
||||
const existing = await tx.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.participants.name, participant.name)
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(schema.participants).values({
|
||||
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++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Import participant expected values (optional field for backward compatibility)
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
const participant = await db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.participants.name, ev.participantName)
|
||||
),
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
// Use map built during participant import — no extra query needed
|
||||
const participantId = participantIdMap.get(
|
||||
`${sportsSeasonId}:${ev.participantName}`
|
||||
);
|
||||
if (!participantId) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingEV = await db.query.participantExpectedValues.findFirst({
|
||||
const existingEV = await tx.query.participantExpectedValues.findFirst({
|
||||
where: and(
|
||||
eq(schema.participantExpectedValues.participantId, participant.id),
|
||||
eq(schema.participantExpectedValues.participantId, participantId),
|
||||
eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||
),
|
||||
});
|
||||
|
|
@ -361,40 +425,101 @@ export async function importSportsDataFromJSON(
|
|||
probSeventh: ev.probSeventh,
|
||||
probEighth: ev.probEighth,
|
||||
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,
|
||||
// Restore original timestamp when present (fixes: calculatedAt lost)
|
||||
...(ev.calculatedAt
|
||||
? { calculatedAt: new Date(ev.calculatedAt) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (existingEV) {
|
||||
if (mode === "merge") {
|
||||
await db
|
||||
await tx
|
||||
.update(schema.participantExpectedValues)
|
||||
.set({ ...evValues, updatedAt: new Date() })
|
||||
.where(eq(schema.participantExpectedValues.id, existingEV.id));
|
||||
await db
|
||||
await tx
|
||||
.update(schema.participants)
|
||||
.set({ expectedValue: ev.expectedValue })
|
||||
.where(eq(schema.participants.id, participant.id));
|
||||
.where(eq(schema.participants.id, participantId));
|
||||
updated++;
|
||||
}
|
||||
} else {
|
||||
await db.insert(schema.participantExpectedValues).values({
|
||||
participantId: participant.id,
|
||||
await tx.insert(schema.participantExpectedValues).values({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
...evValues,
|
||||
});
|
||||
await db
|
||||
await tx
|
||||
.update(schema.participants)
|
||||
.set({ expectedValue: ev.expectedValue })
|
||||
.where(eq(schema.participants.id, participant.id));
|
||||
.where(eq(schema.participants.id, participantId));
|
||||
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) {
|
||||
const existing = await db.query.seasonTemplates.findFirst({
|
||||
const existing = await tx.query.seasonTemplates.findFirst({
|
||||
where: and(
|
||||
eq(schema.seasonTemplates.name, template.name),
|
||||
eq(schema.seasonTemplates.year, template.year)
|
||||
|
|
@ -405,7 +530,7 @@ export async function importSportsDataFromJSON(
|
|||
|
||||
if (existing) {
|
||||
if (mode === "merge") {
|
||||
await db
|
||||
await tx
|
||||
.update(schema.seasonTemplates)
|
||||
.set({
|
||||
description: template.description,
|
||||
|
|
@ -415,15 +540,15 @@ export async function importSportsDataFromJSON(
|
|||
templateId = existing.id;
|
||||
updated++;
|
||||
|
||||
// Remove existing template sports to re-add them
|
||||
await db
|
||||
// Remove existing template sports to re-add them cleanly
|
||||
await tx
|
||||
.delete(schema.seasonTemplateSports)
|
||||
.where(eq(schema.seasonTemplateSports.templateId, templateId));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const [createdTemplate] = await db
|
||||
const [created_] = await tx
|
||||
.insert(schema.seasonTemplates)
|
||||
.values({
|
||||
name: template.name,
|
||||
|
|
@ -432,17 +557,15 @@ export async function importSportsDataFromJSON(
|
|||
isActive: template.isActive,
|
||||
})
|
||||
.returning();
|
||||
templateId = createdTemplate.id;
|
||||
templateId = created_.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({
|
||||
await tx.insert(schema.seasonTemplateSports).values({
|
||||
templateId,
|
||||
sportsSeasonId,
|
||||
});
|
||||
|
|
@ -451,4 +574,5 @@ export async function importSportsDataFromJSON(
|
|||
}
|
||||
|
||||
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