2025-10-18 14:55:26 -07:00
|
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
import * as schema from "~/database/schema";
|
2026-02-23 23:23:24 -08:00
|
|
|
import { eq, and, sql } from "drizzle-orm";
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
import { isUserAdminByClerkId } from "~/models/user";
|
2025-10-24 21:12:07 -07:00
|
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
|
|
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
|
|
|
|
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
|
|
|
|
import { getSeasonSportsSimple } from "~/models/season-sport";
|
feat: proactively prune ineligible queue items after each pick (#59)
After every pick, recalculate draft eligibility for all teams and
remove any queued participants whose sport is no longer eligible
(e.g. a team queued a snooker player but just filled their last flex
slot). Previously this was only caught lazily when autodraft fired,
which could pause the draft or pick an unwanted player.
- Add getAllQueuesForSeason to draft-queue.ts — fetches all queue rows
for a season in one query (Map<teamId, QueueItem[]>) instead of N+1
per-team queries
- Add pruneIneligibleQueueItems to draft-utils.ts — uses Promise.all
for the four required data fetches, collects ineligible items in a
single loop pass, warns on orphaned participant references
- Call from both pick paths: executeAutoPick and draft.make-pick.ts
- Emit queue-eligibility-pruned socket event per affected team so the
client updates the queue UI in real time
- Add 5 tests covering: single ineligible removal, all eligible (no-op),
empty queues (no delete called, getTeamQueue never called), mixed
queue (only ineligible item removed), and unknown participant (warn)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 22:07:22 -08:00
|
|
|
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
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
|
|
|
import { getSocketIO } from "../../../server/socket";
|
2025-10-18 14:55:26 -07:00
|
|
|
|
Claude/fix pick timer ghll n (#29)
* Fix force-manual-pick resetting next team's timer to initial time
When a commissioner forced a manual pick, the next team's timer was
being reset to the initial time (2 minutes) instead of carrying
forward their existing time bank balance.
This aligns force-manual-pick with the behavior of regular user picks
and force-autopick: the picking team gets their increment added, and
the next team's timer is left untouched so their bank carries forward.
https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p
* Add regression tests for draft.force-manual-pick timer behavior
18 tests across 5 describe blocks covering:
- Authorization (401/403)
- Input validation (missing fields, bad participant, ineligible sport)
- Successful pick (response shape, draft-complete detection, socket events)
- Timer behavior (increment added to picking team, new timer creation, additive not reset)
- Two regression tests confirming the next team's timer is never touched:
draftTimers.findFirst called exactly once, no timer-update emitted for
next team, db.update called exactly twice (not three times)
https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p
* Add TypeScript types and improve draft validation (#28)
* 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>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 19:29:29 -08:00
|
|
|
import type { ActionFunctionArgs } from "react-router";
|
|
|
|
|
export async function action(args: ActionFunctionArgs) {
|
2025-10-18 14:55:26 -07:00
|
|
|
const { request } = args;
|
Claude/fix pick timer ghll n (#29)
* Fix force-manual-pick resetting next team's timer to initial time
When a commissioner forced a manual pick, the next team's timer was
being reset to the initial time (2 minutes) instead of carrying
forward their existing time bank balance.
This aligns force-manual-pick with the behavior of regular user picks
and force-autopick: the picking team gets their increment added, and
the next team's timer is left untouched so their bank carries forward.
https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p
* Add regression tests for draft.force-manual-pick timer behavior
18 tests across 5 describe blocks covering:
- Authorization (401/403)
- Input validation (missing fields, bad participant, ineligible sport)
- Successful pick (response shape, draft-complete detection, socket events)
- Timer behavior (increment added to picking team, new timer creation, additive not reset)
- Two regression tests confirming the next team's timer is never touched:
draftTimers.findFirst called exactly once, no timer-update emitted for
next team, db.update called exactly twice (not three times)
https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p
* Add TypeScript types and improve draft validation (#28)
* 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>
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 19:29:29 -08:00
|
|
|
const { userId } = await getAuth(args);
|
2025-10-18 14:55:26 -07:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
if (!seasonId || !participantId) {
|
|
|
|
|
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
|
|
|
// Get season details
|
|
|
|
|
const season = await db.query.seasons.findFirst({
|
|
|
|
|
where: eq(schema.seasons.id, seasonId),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!season) {
|
|
|
|
|
return Response.json({ error: "Season not found" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
Add draft clock UI, Fischer increment timer logic, and security fixes (#19)
- Add prominent clock badge to tab bar (lights up on your turn) with
correct Fischer increment chess-clock model: bank starts at initialTime,
+= incrementTime after each pick, other teams' banks untouched
- Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit
tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass,
and full snake-draft lifecycle regression scenarios
- Fix make-pick.ts: add status !== 'draft' and draftPaused server guards
- Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(),
fix timeUsed to store actual timeRemaining at pick moment (not always 120),
add null timer warning, move timer fetch before pick insert
- Fix draft.start.ts: batch timer inserts, guard against empty draftSlots
- Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to
Record<string, number | undefined>
- Fix duplicate animate-pulse (getTimerColorClass already includes it)
- Clamp negative seconds in formatClockTime to guard against timer drift
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:51:12 -08:00
|
|
|
if (season.status !== "draft") {
|
|
|
|
|
return Response.json({ error: "Draft is not currently active" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (season.draftPaused) {
|
|
|
|
|
return Response.json({ error: "Draft is currently paused" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 14:55:26 -07:00
|
|
|
// Get current draft slot (who should be picking now)
|
|
|
|
|
const currentPickNumber = season.currentPickNumber || 1;
|
|
|
|
|
const draftSlots = await db.query.draftSlots.findMany({
|
|
|
|
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
|
|
|
|
orderBy: schema.draftSlots.draftOrder,
|
|
|
|
|
with: {
|
|
|
|
|
team: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const totalTeams = draftSlots.length;
|
2026-02-23 23:23:24 -08:00
|
|
|
const { round: currentRound, pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
2025-10-18 14:55:26 -07:00
|
|
|
const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
|
|
|
|
|
|
|
|
|
|
if (!currentDraftSlot) {
|
|
|
|
|
return Response.json({ error: "Invalid draft state" }, { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
// Check permissions: must be team owner or commissioner/admin
|
|
|
|
|
// Capture both results to set pickedByType accurately in the audit record
|
2025-10-18 14:55:26 -07:00
|
|
|
const isTeamOwner = currentDraftSlot.team.ownerId === userId;
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
const [isAdmin, commissionerRecord] = await Promise.all([
|
|
|
|
|
isUserAdminByClerkId(userId),
|
|
|
|
|
db.query.commissioners.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.commissioners.leagueId, season.leagueId),
|
|
|
|
|
eq(schema.commissioners.userId, userId)
|
|
|
|
|
),
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (!isTeamOwner && !isAdmin && !commissionerRecord) {
|
2026-02-23 23:23:24 -08:00
|
|
|
return Response.json({ error: "You do not have permission to pick for this team" }, { status: 403 });
|
2025-10-18 14:55:26 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if participant is already drafted
|
|
|
|
|
const existingPick = await db.query.draftPicks.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftPicks.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftPicks.participantId, participantId)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (existingPick) {
|
|
|
|
|
return Response.json({ error: "Participant already drafted" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get participant details
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 21:12:07 -07:00
|
|
|
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
|
|
|
|
|
const allPicks = await getDraftPicksWithSports(seasonId);
|
|
|
|
|
const teamPicks = await getTeamDraftPicksWithSports(currentDraftSlot.teamId, seasonId);
|
|
|
|
|
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
|
|
|
|
|
const seasonSports = await getSeasonSportsSimple(seasonId);
|
|
|
|
|
|
|
|
|
|
// Get all teams for the season
|
|
|
|
|
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
|
|
|
|
|
|
|
|
|
|
const eligibility = calculateDraftEligibility(
|
|
|
|
|
currentDraftSlot.teamId,
|
|
|
|
|
teamPicks,
|
|
|
|
|
allPicks,
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 23:23:24 -08:00
|
|
|
// Snapshot the team's time bank before the pick (used for audit / pick history)
|
|
|
|
|
const timerSnapshot = await db.query.draftTimers.findFirst({
|
|
|
|
|
where: and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-18 14:55:26 -07:00
|
|
|
// Create the draft pick
|
|
|
|
|
const [draftPick] = await db
|
|
|
|
|
.insert(schema.draftPicks)
|
|
|
|
|
.values({
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentDraftSlot.teamId,
|
|
|
|
|
participantId,
|
|
|
|
|
pickNumber: currentPickNumber,
|
|
|
|
|
round: currentRound,
|
|
|
|
|
pickInRound,
|
|
|
|
|
pickedByUserId: userId,
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin",
|
2026-02-23 23:23:24 -08:00
|
|
|
timeUsed: timerSnapshot?.timeRemaining ?? 0,
|
2025-10-18 14:55:26 -07:00
|
|
|
})
|
|
|
|
|
.returning();
|
|
|
|
|
|
2025-10-18 23:13:04 -07:00
|
|
|
// Remove from ALL team queues in this season (participant is now drafted)
|
|
|
|
|
await db
|
|
|
|
|
.delete(schema.draftQueue)
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.draftQueue.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftQueue.participantId, participantId)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-24 21:46:55 -07:00
|
|
|
// Notify all clients that this participant was removed from queues
|
|
|
|
|
try {
|
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
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("participant-removed-from-queues", {
|
2025-10-24 21:46:55 -07:00
|
|
|
participantId,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Socket.IO participant-removed-from-queues error:", error);
|
|
|
|
|
}
|
|
|
|
|
|
feat: proactively prune ineligible queue items after each pick (#59)
After every pick, recalculate draft eligibility for all teams and
remove any queued participants whose sport is no longer eligible
(e.g. a team queued a snooker player but just filled their last flex
slot). Previously this was only caught lazily when autodraft fired,
which could pause the draft or pick an unwanted player.
- Add getAllQueuesForSeason to draft-queue.ts — fetches all queue rows
for a season in one query (Map<teamId, QueueItem[]>) instead of N+1
per-team queries
- Add pruneIneligibleQueueItems to draft-utils.ts — uses Promise.all
for the four required data fetches, collects ineligible items in a
single loop pass, warns on orphaned participant references
- Call from both pick paths: executeAutoPick and draft.make-pick.ts
- Emit queue-eligibility-pruned socket event per affected team so the
client updates the queue UI in real time
- Add 5 tests covering: single ineligible removal, all eligible (no-op),
empty queues (no delete called, getTeamQueue never called), mixed
queue (only ineligible item removed), and unknown participant (warn)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 22:07:22 -08:00
|
|
|
// Proactively prune queue items that are now ineligible due to this pick
|
|
|
|
|
// (e.g. a team queued a snooker player but just filled their last flex slot)
|
|
|
|
|
try {
|
|
|
|
|
const allTeamIds = draftSlots.map((slot) => slot.teamId);
|
|
|
|
|
const prunedQueues = await pruneIneligibleQueueItems({
|
|
|
|
|
seasonId,
|
|
|
|
|
draftRounds: season.draftRounds,
|
|
|
|
|
allTeamIds,
|
|
|
|
|
db,
|
|
|
|
|
});
|
|
|
|
|
for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("queue-eligibility-pruned", {
|
|
|
|
|
teamId: prunedTeamId,
|
|
|
|
|
removedParticipantIds,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Queue pruning error after pick:", error);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 22:11:10 -07:00
|
|
|
// Calculate next pick info (before updating season)
|
|
|
|
|
const nextPickNumber = currentPickNumber + 1;
|
|
|
|
|
const totalPicks = totalTeams * season.draftRounds;
|
|
|
|
|
const isDraftComplete = nextPickNumber > totalPicks;
|
|
|
|
|
|
2026-03-20 21:36:39 -07:00
|
|
|
// Update the picking team's timer after their pick.
|
|
|
|
|
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
|
|
|
|
// Chess clock, owner pick: add the increment (reward for picking promptly).
|
|
|
|
|
// Chess clock, commissioner/admin pick: no change — forced picks don't earn bank time.
|
Add draft clock UI, Fischer increment timer logic, and security fixes (#19)
- Add prominent clock badge to tab bar (lights up on your turn) with
correct Fischer increment chess-clock model: bank starts at initialTime,
+= incrementTime after each pick, other teams' banks untouched
- Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit
tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass,
and full snake-draft lifecycle regression scenarios
- Fix make-pick.ts: add status !== 'draft' and draftPaused server guards
- Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(),
fix timeUsed to store actual timeRemaining at pick moment (not always 120),
add null timer warning, move timer fetch before pick insert
- Fix draft.start.ts: batch timer inserts, guard against empty draftSlots
- Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to
Record<string, number | undefined>
- Fix duplicate animate-pulse (getTimerColorClass already includes it)
- Clamp negative seconds in formatClockTime to guard against timer drift
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:51:12 -08:00
|
|
|
const incrementTime = season.draftIncrementTime || 30;
|
2026-03-20 21:36:39 -07:00
|
|
|
let newTimeRemaining: number;
|
|
|
|
|
|
|
|
|
|
let updatedTimer: { timeRemaining: number } | undefined;
|
|
|
|
|
|
|
|
|
|
if (season.draftTimerMode === "standard") {
|
|
|
|
|
// Atomic reset so the timer loop cannot race with this write.
|
|
|
|
|
[updatedTimer] = await db
|
|
|
|
|
.update(schema.draftTimers)
|
|
|
|
|
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
|
|
|
|
)
|
2026-02-23 23:23:24 -08:00
|
|
|
)
|
2026-03-20 21:36:39 -07:00
|
|
|
.returning();
|
|
|
|
|
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
|
|
|
} else if (isTeamOwner) {
|
|
|
|
|
// Chess clock, owner pick: earn the increment (atomic add).
|
|
|
|
|
[updatedTimer] = await db
|
|
|
|
|
.update(schema.draftTimers)
|
|
|
|
|
.set({
|
|
|
|
|
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})
|
|
|
|
|
.where(
|
|
|
|
|
and(
|
|
|
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
|
|
|
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.returning();
|
|
|
|
|
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
|
|
|
} else {
|
|
|
|
|
// Chess clock, commissioner/admin pick: bank is frozen — no DB update needed.
|
|
|
|
|
newTimeRemaining = timerSnapshot?.timeRemaining ?? 0;
|
|
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-03-20 21:36:39 -07:00
|
|
|
// If the timer row didn't exist yet, seed it (standard and chess clock owner branches only).
|
|
|
|
|
if (!updatedTimer && (season.draftTimerMode === "standard" || isTeamOwner)) {
|
2026-02-22 17:27:16 -08:00
|
|
|
await db.insert(schema.draftTimers).values({
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentDraftSlot.teamId,
|
|
|
|
|
timeRemaining: newTimeRemaining,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-18 23:13:04 -07:00
|
|
|
|
2026-02-22 17:27:16 -08:00
|
|
|
try {
|
|
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
|
|
|
|
seasonId,
|
|
|
|
|
teamId: currentDraftSlot.teamId,
|
|
|
|
|
timeRemaining: newTimeRemaining,
|
2026-02-23 23:23:24 -08:00
|
|
|
currentPickNumber: nextPickNumber,
|
2026-02-22 17:27:16 -08:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Socket.IO timer-update error:", error);
|
2025-10-18 23:13:04 -07:00
|
|
|
}
|
|
|
|
|
|
Add draft clock UI, Fischer increment timer logic, and security fixes (#19)
- Add prominent clock badge to tab bar (lights up on your turn) with
correct Fischer increment chess-clock model: bank starts at initialTime,
+= incrementTime after each pick, other teams' banks untouched
- Extract pure timer helpers to app/lib/draft-timer.ts and add 29 unit
tests covering formatClockTime, calculateTimeAfterPick, getTimerColorClass,
and full snake-draft lifecycle regression scenarios
- Fix make-pick.ts: add status !== 'draft' and draftPaused server guards
- Fix draft-utils.ts: replace (global as any).__socketIO with getSocketIO(),
fix timeUsed to store actual timeRemaining at pick moment (not always 120),
add null timer warning, move timer fetch before pick insert
- Fix draft.start.ts: batch timer inserts, guard against empty draftSlots
- Fix DraftGridSection: memoize currentTeamId, widen teamTimers type to
Record<string, number | undefined>
- Fix duplicate animate-pulse (getTimerColorClass already includes it)
- Clamp negative seconds in formatClockTime to guard against timer drift
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:51:12 -08:00
|
|
|
// Next team's timer is unchanged — their bank carries forward as-is
|
|
|
|
|
// (no emit needed; the timer system will start decrementing their existing bank)
|
2025-10-25 22:11:10 -07:00
|
|
|
|
|
|
|
|
// Update season's current pick number (AFTER initializing next timer to prevent race condition)
|
2025-10-18 14:55:26 -07:00
|
|
|
await db
|
|
|
|
|
.update(schema.seasons)
|
|
|
|
|
.set({
|
|
|
|
|
currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
|
|
|
|
status: isDraftComplete ? "active" : season.status,
|
|
|
|
|
})
|
|
|
|
|
.where(eq(schema.seasons.id, seasonId));
|
|
|
|
|
|
|
|
|
|
// Emit socket event
|
|
|
|
|
try {
|
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
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("pick-made", {
|
2025-10-18 14:55:26 -07:00
|
|
|
pick: {
|
|
|
|
|
...draftPick,
|
|
|
|
|
team: currentDraftSlot.team,
|
|
|
|
|
participant: {
|
|
|
|
|
...participant,
|
|
|
|
|
sport: participant.sportsSeason.sport,
|
|
|
|
|
},
|
|
|
|
|
sport: participant.sportsSeason.sport,
|
|
|
|
|
},
|
|
|
|
|
nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
|
|
|
|
isDraftComplete,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isDraftComplete) {
|
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
|
|
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
2025-10-18 14:55:26 -07:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Socket.IO error:", error);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-25 22:11:10 -07:00
|
|
|
// Check if next team has autodraft enabled and trigger immediately
|
|
|
|
|
if (!isDraftComplete) {
|
2026-02-23 23:23:24 -08:00
|
|
|
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
|
|
|
|
if (!freshSeason?.draftPaused) {
|
|
|
|
|
await checkAndTriggerNextAutodraft({
|
|
|
|
|
seasonId,
|
|
|
|
|
nextPickNumber,
|
|
|
|
|
totalTeams,
|
|
|
|
|
draftSlots,
|
|
|
|
|
db,
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-25 22:11:10 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Response.json({
|
|
|
|
|
success: true,
|
2025-10-18 14:55:26 -07:00
|
|
|
pick: draftPick,
|
|
|
|
|
nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
|
|
|
|
isDraftComplete,
|
|
|
|
|
});
|
|
|
|
|
}
|