* 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>
192 lines
5.9 KiB
TypeScript
192 lines
5.9 KiB
TypeScript
import { getAuth } from "@clerk/react-router/server";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
|
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
|
import { getSeasonSportsSimple } from "~/models/season-sport";
|
|
import { getSocketIO } from "../../../server/socket";
|
|
|
|
import type { ActionFunctionArgs } from "react-router";
|
|
export async function action(args: ActionFunctionArgs) {
|
|
const { request } = args;
|
|
const { userId } = await getAuth(args);
|
|
|
|
if (!userId) {
|
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const seasonId = formData.get("seasonId") as string;
|
|
const participantId = formData.get("participantId") as string;
|
|
const pickNumber = parseInt(formData.get("pickNumber") as string);
|
|
|
|
if (!seasonId || !participantId || isNaN(pickNumber) || pickNumber < 1) {
|
|
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
});
|
|
|
|
if (!season) {
|
|
return Response.json({ error: "Season not found" }, { status: 404 });
|
|
}
|
|
|
|
const isCommissioner = await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, season.leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
});
|
|
|
|
if (!isCommissioner) {
|
|
return Response.json({ error: "Only commissioners can replace picks" }, { status: 403 });
|
|
}
|
|
|
|
if (season.status === "pre_draft") {
|
|
return Response.json({ error: "Draft has not started" }, { status: 400 });
|
|
}
|
|
|
|
// Get the existing pick at this slot
|
|
const existingPick = await db.query.draftPicks.findFirst({
|
|
where: and(
|
|
eq(schema.draftPicks.seasonId, seasonId),
|
|
eq(schema.draftPicks.pickNumber, pickNumber)
|
|
),
|
|
});
|
|
|
|
if (!existingPick) {
|
|
return Response.json({ error: "No pick found at this slot" }, { status: 404 });
|
|
}
|
|
|
|
// Use the team from the DB record — don't trust the client-supplied teamId
|
|
const teamId = existingPick.teamId;
|
|
const oldParticipantId = existingPick.participantId;
|
|
|
|
// Check new participant isn't already drafted elsewhere (skip if same participant)
|
|
if (participantId !== oldParticipantId) {
|
|
const alreadyDrafted = await db.query.draftPicks.findFirst({
|
|
where: and(
|
|
eq(schema.draftPicks.seasonId, seasonId),
|
|
eq(schema.draftPicks.participantId, participantId)
|
|
),
|
|
});
|
|
|
|
if (alreadyDrafted) {
|
|
return Response.json({ error: "Participant already drafted" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
const participant = await db.query.participants.findFirst({
|
|
where: eq(schema.participants.id, participantId),
|
|
with: {
|
|
sportsSeason: { with: { sport: true } },
|
|
},
|
|
});
|
|
|
|
if (!participant) {
|
|
return Response.json({ error: "Participant not found" }, { status: 404 });
|
|
}
|
|
|
|
// Eligibility check — exclude the pick being replaced so its slot is treated as free
|
|
const draftSlots = await db.query.draftSlots.findMany({
|
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
|
orderBy: schema.draftSlots.draftOrder,
|
|
with: { team: true },
|
|
});
|
|
|
|
const allPicksWithSports = await getDraftPicksWithSports(seasonId);
|
|
const teamPicksWithSports = await getTeamDraftPicksWithSports(teamId, seasonId);
|
|
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
|
|
const seasonSports = await getSeasonSportsSimple(seasonId);
|
|
|
|
// Exclude old participant so that slot is "open" for eligibility purposes
|
|
const allPicksExcluding = allPicksWithSports.filter(
|
|
(p) => p.participant.id !== oldParticipantId
|
|
);
|
|
const teamPicksExcluding = teamPicksWithSports.filter(
|
|
(p) => p.participant.id !== oldParticipantId
|
|
);
|
|
|
|
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
|
|
|
|
const eligibility = calculateDraftEligibility(
|
|
teamId,
|
|
teamPicksExcluding,
|
|
allPicksExcluding,
|
|
allParticipants,
|
|
seasonSports,
|
|
season.draftRounds,
|
|
allTeams
|
|
);
|
|
|
|
const sportId = participant.sportsSeason.sport.id;
|
|
if (!eligibility.eligibleSportIds.has(sportId)) {
|
|
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
|
|
return Response.json({ error: reason }, { status: 400 });
|
|
}
|
|
|
|
// Update the pick in-place
|
|
const [updatedPick] = await db
|
|
.update(schema.draftPicks)
|
|
.set({
|
|
participantId,
|
|
pickedByUserId: userId,
|
|
pickedByType: "commissioner",
|
|
})
|
|
.where(
|
|
and(
|
|
eq(schema.draftPicks.seasonId, seasonId),
|
|
eq(schema.draftPicks.pickNumber, pickNumber)
|
|
)
|
|
)
|
|
.returning();
|
|
|
|
// Remove new participant from all team queues
|
|
await db
|
|
.delete(schema.draftQueue)
|
|
.where(
|
|
and(
|
|
eq(schema.draftQueue.seasonId, seasonId),
|
|
eq(schema.draftQueue.participantId, participantId)
|
|
)
|
|
);
|
|
|
|
// Emit socket events
|
|
try {
|
|
const io = getSocketIO();
|
|
const team = draftSlots.find((slot) => slot.team.id === teamId)?.team;
|
|
|
|
io.to(`draft-${seasonId}`).emit("pick-replaced", {
|
|
seasonId,
|
|
pickNumber,
|
|
oldParticipantId,
|
|
pick: {
|
|
id: updatedPick.id,
|
|
pickNumber: updatedPick.pickNumber,
|
|
round: updatedPick.round,
|
|
pickInRound: updatedPick.pickInRound,
|
|
timeUsed: updatedPick.timeUsed,
|
|
team,
|
|
participant: {
|
|
id: participant.id,
|
|
name: participant.name,
|
|
sport: participant.sportsSeason.sport,
|
|
},
|
|
sport: participant.sportsSeason.sport,
|
|
},
|
|
});
|
|
|
|
io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", {
|
|
participantId,
|
|
});
|
|
} catch (error) {
|
|
console.error("Socket.IO error:", error);
|
|
}
|
|
|
|
return Response.json({ success: true });
|
|
}
|