* Code review fixes: type safety, security hardening, and dead code removal
- Fix Socket.IO event types: draft-paused and draft-resumed were typed as
() => void but are emitted with { seasonId, paused } data payloads
- Fix draft.force-manual-pick: add missing season.status === "draft" guard
so commissioners cannot force picks outside an active draft; add duplicate
pick-number check so a slot cannot be assigned two picks (the previous
code only checked participant uniqueness, not slot uniqueness)
- Replace args: any with ActionFunctionArgs / Route.LoaderArgs across all
API routes and league loaders; replace (auth as any).userId casts with
proper const { userId } = await getAuth(args) destructuring
- Remove unused isSnakeDraft = true dead variable from draft.make-pick
- Replace autodraftSettings: any and draftSlots: any[] in draft-utils with
properly typed InferSelectModel / DraftSlot types
- Update force-manual-pick tests: sequence draftPicks.findFirst mock for
the two-call flow; add new tests for status-check and slot-uniqueness
https://claude.ai/code/session_01FKq2gPFYpgdfxr8cw4Z2AZ
* Fix RouterContextProvider type errors in action test files
Cast context argument to RouterContextProvider in test helpers so
ActionFunctionArgs strict typing is satisfied without weakening the
production action signatures back to any.
https://claude.ai/code/session_01FKq2gPFYpgdfxr8cw4Z2AZ
---------
Co-authored-by: Claude <noreply@anthropic.com>
198 lines
6.2 KiB
TypeScript
198 lines
6.2 KiB
TypeScript
import { useLoaderData } from "react-router";
|
|
import { eq, asc, and } from "drizzle-orm";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { DraftGrid } from "~/components/DraftGrid";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useState, useEffect } from "react";
|
|
import { buildOwnerMap } from "~/lib/owner-map";
|
|
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const { params } = args;
|
|
const { leagueId, seasonId } = params;
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
// Get season details
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
with: {
|
|
league: true,
|
|
},
|
|
});
|
|
|
|
if (!season) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Validate that the season actually belongs to the league in the URL
|
|
if (season.leagueId !== leagueId) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Check access: public boards are accessible to everyone without auth
|
|
if (!season.league.isPublicDraftBoard) {
|
|
// Not public - check if the user is a league member or commissioner
|
|
const { userId } = await getAuth(args);
|
|
|
|
if (!userId) {
|
|
throw new Response("This draft board is not public", { status: 403 });
|
|
}
|
|
|
|
// Check if user is a commissioner
|
|
const isCommissioner = await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, season.leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
});
|
|
|
|
// Check if user has a team in this season
|
|
const hasTeam = await db.query.teams.findFirst({
|
|
where: and(
|
|
eq(schema.teams.seasonId, seasonId),
|
|
eq(schema.teams.ownerId, userId)
|
|
),
|
|
});
|
|
|
|
if (!isCommissioner && !hasTeam) {
|
|
throw new Response("You don't have access to this draft board", { status: 403 });
|
|
}
|
|
}
|
|
|
|
// Get draft slots (draft order)
|
|
const draftSlots = await db
|
|
.select({
|
|
id: schema.draftSlots.id,
|
|
draftOrder: schema.draftSlots.draftOrder,
|
|
team: schema.teams,
|
|
})
|
|
.from(schema.draftSlots)
|
|
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
|
|
.where(eq(schema.draftSlots.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftSlots.draftOrder));
|
|
|
|
// Get all draft picks with participant and sport info
|
|
const draftPicks = await db
|
|
.select({
|
|
id: schema.draftPicks.id,
|
|
pickNumber: schema.draftPicks.pickNumber,
|
|
round: schema.draftPicks.round,
|
|
pickInRound: schema.draftPicks.pickInRound,
|
|
team: schema.teams,
|
|
participant: schema.participants,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.participants,
|
|
eq(schema.draftPicks.participantId, schema.participants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
|
|
.where(eq(schema.draftPicks.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftPicks.pickNumber));
|
|
|
|
const ownerMap = await buildOwnerMap(draftSlots);
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
ownerMap,
|
|
};
|
|
}
|
|
|
|
export default function DraftBoard() {
|
|
const { season, draftSlots, draftPicks: initialPicks, ownerMap } = useLoaderData<typeof loader>();
|
|
const { isConnected, on, off } = useDraftSocket(season.id);
|
|
const [picks, setPicks] = useState(initialPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
|
|
// Listen for new picks (only if draft is still active)
|
|
useEffect(() => {
|
|
if (season.status !== "draft") return;
|
|
|
|
const handlePickMade = (data: any) => {
|
|
setPicks((prev: any) => [...prev, data.pick]);
|
|
setCurrentPick(data.nextPickNumber);
|
|
};
|
|
|
|
on("pick-made", handlePickMade);
|
|
|
|
return () => {
|
|
off("pick-made", handlePickMade);
|
|
};
|
|
}, [on, off, season.status]);
|
|
|
|
// Generate draft grid
|
|
const totalTeams = draftSlots.length;
|
|
const totalRounds = season.draftRounds || 1;
|
|
const draftGrid: any[][] = [];
|
|
|
|
for (let round = 0; round < totalRounds; round++) {
|
|
const roundPicks: any[] = [];
|
|
for (let teamIndex = 0; teamIndex < totalTeams; teamIndex++) {
|
|
const pickNumber = round * totalTeams + teamIndex + 1;
|
|
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
|
|
roundPicks.push(pick || null);
|
|
}
|
|
draftGrid.push(roundPicks);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* Header */}
|
|
<div className="border-b bg-card sticky top-0 z-10">
|
|
<div className="w-full px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">
|
|
{season.league.name} - {season.year} Draft Board
|
|
</h1>
|
|
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
|
<span>Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1}</span>
|
|
<span>Pick: {currentPick}</span>
|
|
<span className="capitalize">
|
|
{season.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{season.status === "draft" && (
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
|
}`}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{isConnected ? "Connected" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Draft Grid */}
|
|
<div className="w-full px-4 py-4">
|
|
<DraftGrid
|
|
draftSlots={draftSlots}
|
|
draftGrid={draftGrid}
|
|
currentPick={currentPick}
|
|
ownerMap={ownerMap}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|