2025-10-20 15:03:11 -07:00
|
|
|
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";
|
2026-02-22 16:56:07 -08:00
|
|
|
import { buildOwnerMap } from "~/lib/owner-map";
|
2025-10-20 15:03:11 -07:00
|
|
|
|
|
|
|
|
export async function loader(args: any) {
|
|
|
|
|
const { params } = args;
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
const { leagueId, seasonId } = params;
|
2025-10-20 15:03:11 -07:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
// 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 auth = await getAuth(args);
|
|
|
|
|
const userId = (auth as any).userId as string | null;
|
|
|
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
|
throw new Response("This draft board is not public", { status: 403 });
|
|
|
|
|
}
|
2025-10-20 15:03:11 -07:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
if (!isCommissioner && !hasTeam) {
|
|
|
|
|
throw new Response("You don't have access to this draft board", { status: 403 });
|
|
|
|
|
}
|
2025-10-20 15:03:11 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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));
|
|
|
|
|
|
2026-02-22 16:56:07 -08:00
|
|
|
const ownerMap = await buildOwnerMap(draftSlots);
|
|
|
|
|
|
2025-10-20 15:03:11 -07:00
|
|
|
return {
|
|
|
|
|
season,
|
|
|
|
|
draftSlots,
|
|
|
|
|
draftPicks,
|
2026-02-22 16:56:07 -08:00
|
|
|
ownerMap,
|
2025-10-20 15:03:11 -07:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function DraftBoard() {
|
2026-02-22 16:56:07 -08:00
|
|
|
const { season, draftSlots, draftPicks: initialPicks, ownerMap } = useLoaderData<typeof loader>();
|
2025-10-20 15:03:11 -07:00
|
|
|
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">
|
2026-02-20 11:16:34 -08:00
|
|
|
<span>Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1}</span>
|
2025-10-20 15:03:11 -07:00
|
|
|
<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 ${
|
2026-02-20 19:26:11 -08:00
|
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
2025-10-20 15:03:11 -07:00
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm font-medium">
|
Improve draft room UX with better error handling and UI refinements (#17)
* Remove pause/resume controls when draft is complete, rename Live to Connected
- Hide Pause/Resume Draft buttons when isDraftComplete is true
- Change 'Live' status indicator to 'Connected' in both draft room and draft board views
https://claude.ai/code/session_01AUaKzx465NrY29Qv6MVwjC
* Fix code review issues in draft room: security, bugs, and quality
Security:
- Fix inconsistent commissioner check: draft.start, force-autopick, force-manual-pick,
and make-pick all now query the commissioners table instead of league.createdBy,
so co-commissioners have consistent access to all draft controls
Bugs:
- canPick now includes !isPaused so the UI correctly blocks picks during a pause
- isDraftComplete initial state now covers 'completed' season status, not just 'active'
- Guard JSON.parse in queue.reorder.ts with try/catch to return 400 instead of 500
Code quality:
- Add error handling (try/catch + toast) to handlePauseDraft, handleResumeDraft,
handleRemoveFromQueue, and handleReorderQueue
- Replace alert() with toast.error() in handleMakePick, handleForceAutopick,
handleForceManualPick for consistent UX
- Memoize filteredParticipants with useMemo to avoid recomputing on every render
- Replace custom force-pick dialog div with ShadCN Dialog component for proper
keyboard support (Escape to close, focus trap, accessible markup); add dialog.tsx
- Remove console.log debug statements from socket event handlers and API routes
- Replace (global as any).__socketIO with getSocketIO() across all API routes
- Replace window.location.reload() in handleStartDraft with useRevalidator
https://claude.ai/code/session_01AUaKzx465NrY29Qv6MVwjC
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 21:50:27 -08:00
|
|
|
{isConnected ? "Connected" : "Disconnected"}
|
2025-10-20 15:03:11 -07:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Draft Grid */}
|
|
|
|
|
<div className="w-full px-4 py-4">
|
|
|
|
|
<DraftGrid
|
|
|
|
|
draftSlots={draftSlots}
|
|
|
|
|
draftGrid={draftGrid}
|
|
|
|
|
currentPick={currentPick}
|
2026-02-22 16:56:07 -08:00
|
|
|
ownerMap={ownerMap}
|
2025-10-20 15:03:11 -07:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|