fix: harden draft timer system with race-condition safety and DRY refactor (#34)

- Fix settings action silently resetting timer values when draft speed
  select is disabled (add null guard before overwriting draftInitialTime/
  draftIncrementTime)
- Make timer decrement and pick increment atomic using SQL expressions to
  prevent read-modify-write races between the timer loop and HTTP handlers
- Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent
  duplicate picks from concurrent requests (TOCTOU guard)
- Add socket join-draft team ownership validation via DB query
- Add iteration cap to autodraft chain while loop (max = totalTeams)
- Add draftPaused re-check before firing autodraft chain in make-pick
  and force-manual-pick
- Consolidate all snake draft calculations into calculatePickInfo (DRY);
  remove duplicated logic from timer.ts, make-pick, force-manual-pick,
  executeAutoPick, and checkAndTriggerNextAutodraft
- Fix calculatePickInfo to return snake-adjusted pickInRound matching
  draftOrder values instead of raw pre-snake value
- Fix timer-update socket events to emit nextPickNumber after a pick
  instead of the already-completed currentPickNumber
- Fix force-manual-pick to validate submitted teamId matches the team
  whose turn it actually is at the given pick number
- Replace inline timer init in draft.start with deleteSeasonTimers +
  initializeDraftTimers model functions (dead code fix)
- Fix draft loader to not crash when getTeamQueue fails (returns [])
- Fix misleading 403 message for commissioner picks
- Remove dead hidden inputs from league settings form
- Document connectedTeams single-instance limitation in socket.ts

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-23 23:23:24 -08:00 committed by GitHub
parent fd46e4f0b5
commit f33e39264d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 234 additions and 191 deletions

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, notInArray, desc, inArray } from "drizzle-orm"; import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { getTeamQueue } from "./draft-queue"; import { getTeamQueue } from "./draft-queue";
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick"; import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
@ -27,17 +27,15 @@ export async function checkAndTriggerNextAutodraft(params: {
let currentPickNumber = params.nextPickNumber; let currentPickNumber = params.nextPickNumber;
// Cap iterations at totalTeams: in the worst case every team has autodraft enabled,
// so we make at most totalTeams consecutive picks before handing back to the timer loop.
const maxIterations = params.totalTeams;
let iterations = 0;
// Iteratively execute autodraft picks for consecutive teams with autodraft enabled // Iteratively execute autodraft picks for consecutive teams with autodraft enabled
while (true) { while (iterations < maxIterations) {
// Calculate which team is next using snake draft logic iterations++;
const nextRound = Math.ceil(currentPickNumber / totalTeams); const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const isNextRoundEven = nextRound % 2 === 0;
let nextPickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
if (isNextRoundEven) {
nextPickInRound = totalTeams - nextPickInRound + 1;
}
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
if (!nextDraftSlot) return; if (!nextDraftSlot) return;
@ -316,19 +314,21 @@ export async function getTopAvailableParticipant(
} }
/** /**
* Calculate the current pick based on draft order and round * Calculate the current pick based on draft order and round (snake draft).
* Returns pickInRound that is already snake-adjusted and matches draftOrder values.
*/ */
export function calculatePickInfo( export function calculatePickInfo(
pickNumber: number, pickNumber: number,
teamCount: number teamCount: number
): { round: number; pickInRound: number; teamIndex: number } { ): { round: number; pickInRound: number; teamIndex: number } {
const round = Math.ceil(pickNumber / teamCount); const round = Math.ceil(pickNumber / teamCount);
const pickInRound = ((pickNumber - 1) % teamCount) + 1; const rawPickInRound = ((pickNumber - 1) % teamCount) + 1;
// Snake draft: odd rounds go forward, even rounds go backward // Snake draft: odd rounds go forward, even rounds go backward
const isOddRound = round % 2 === 1; const isOddRound = round % 2 === 1;
const teamIndex = isOddRound ? pickInRound - 1 : teamCount - pickInRound; const teamIndex = isOddRound ? rawPickInRound - 1 : teamCount - rawPickInRound;
const pickInRound = teamIndex + 1; // snake-adjusted, 1-based, matches draftOrder
return { round, pickInRound, teamIndex }; return { round, pickInRound, teamIndex };
} }
@ -437,7 +437,7 @@ export async function executeAutoPick(params: {
// Get draft slots to calculate round/pickInRound and get all team IDs // Get draft slots to calculate round/pickInRound and get all team IDs
const draftSlots = await db.query.draftSlots.findMany({ const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId), where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: schema.draftSlots.draftOrder, orderBy: asc(schema.draftSlots.draftOrder),
with: { with: {
team: true, team: true,
}, },
@ -488,15 +488,7 @@ export async function executeAutoPick(params: {
}; };
} }
// Calculate round and pickInRound with snake draft logic const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams);
const currentRound = Math.ceil(pickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
// Apply snake draft reversal for even rounds
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
// Determine pickedByUserId based on trigger // Determine pickedByUserId based on trigger
const pickedByUserId = triggeredBy === "commissioner" const pickedByUserId = triggeredBy === "commissioner"
@ -542,24 +534,32 @@ export async function executeAutoPick(params: {
const totalPicks = totalTeams * season.draftRounds; const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks; const isDraftComplete = nextPickNumber > totalPicks;
// Add increment to the team that just picked (post-pick reward) // Add increment to the team that just picked (post-pick reward).
if (currentTimer) { // Atomic update so the timer loop cannot overwrite this via a concurrent decrement.
const newTimeRemaining = currentTimer.timeRemaining + incrementTime; const [updatedTimer] = await db
await db .update(schema.draftTimers)
.update(schema.draftTimers) .set({
.set({ timeRemaining: newTimeRemaining, updatedAt: new Date() }) timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
.where(eq(schema.draftTimers.id, currentTimer.id)); updatedAt: new Date(),
})
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId)
)
)
.returning();
if (updatedTimer) {
console.log( console.log(
`[AutoPick] Added ${incrementTime}s increment to team ${teamId} after pick: ${currentTimer.timeRemaining}s → ${newTimeRemaining}s` `[AutoPick] Added ${incrementTime}s increment to team ${teamId} after pick${updatedTimer.timeRemaining}s`
); );
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
seasonId, seasonId,
teamId, teamId,
timeRemaining: newTimeRemaining, timeRemaining: updatedTimer.timeRemaining,
currentPickNumber: pickNumber, currentPickNumber: nextPickNumber,
}); });
} catch (error) { } catch (error) {
console.error("[AutoPick] Socket.IO timer-update error:", error); console.error("[AutoPick] Socket.IO timer-update error:", error);

View file

@ -26,6 +26,7 @@ vi.mock("~/lib/draft-eligibility", () => ({
})); }));
vi.mock("~/models/draft-utils", () => ({ vi.mock("~/models/draft-utils", () => ({
checkAndTriggerNextAutodraft: vi.fn(), checkAndTriggerNextAutodraft: vi.fn(),
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
})); }));
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@ -346,28 +347,23 @@ describe("draft.force-manual-pick action", () => {
describe("timer behavior", () => { describe("timer behavior", () => {
it("adds draftIncrementTime to the picking team's time bank", async () => { it("adds draftIncrementTime to the picking team's time bank", async () => {
// Team has 75s; increment is 30s → expected new balance: 105s // Timer has 75s; increment is 30s → DB atomically returns new balance: 105s
mockDb.query.draftTimers.findFirst.mockResolvedValue({ mockDb.returning
id: "timer-1", .mockResolvedValueOnce([mockDraftPick])
seasonId: SEASON_ID, .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
teamId: TEAM_ID,
timeRemaining: 75,
});
await action({ request: defaultPickRequest(), params: {}, context: ctx }); await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockDb.set).toHaveBeenCalledWith( expect(mockSocketIO.emit).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 105 }) "timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
); );
}); });
it("emits timer-update for the picking team with their incremented balance", async () => { it("emits timer-update for the picking team with their incremented balance", async () => {
mockDb.query.draftTimers.findFirst.mockResolvedValue({ mockDb.returning
id: "timer-1", .mockResolvedValueOnce([mockDraftPick])
seasonId: SEASON_ID, .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
teamId: TEAM_ID,
timeRemaining: 75,
});
await action({ request: defaultPickRequest(), params: {}, context: ctx }); await action({ request: defaultPickRequest(), params: {}, context: ctx });
@ -375,33 +371,34 @@ describe("draft.force-manual-pick action", () => {
seasonId: SEASON_ID, seasonId: SEASON_ID,
teamId: TEAM_ID, teamId: TEAM_ID,
timeRemaining: 105, timeRemaining: 105,
currentPickNumber: 1, currentPickNumber: 2,
}); });
}); });
it("creates a new timer for the picking team if they have no existing timer", async () => { it("creates a new timer for the picking team if they have no existing timer", async () => {
mockDb.query.draftTimers.findFirst.mockResolvedValue(null); // update() returns [] when no timer row exists → triggers the insert fallback
mockDb.returning
.mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([]);
await action({ request: defaultPickRequest(), params: {}, context: ctx }); await action({ request: defaultPickRequest(), params: {}, context: ctx });
// insert should be called for: (1) draft pick, (2) new timer (0 + 30 = 30s) // insert should be called for: (1) draft pick, (2) new timer (increment only: 30s)
expect(mockDb.insert).toHaveBeenCalledTimes(2); expect(mockDb.insert).toHaveBeenCalledTimes(2);
}); });
it("increment is additive, not a flat reset — fast pickers accumulate time", async () => { it("increment is additive, not a flat reset — fast pickers accumulate time", async () => {
// Team used 20s of their 120s initial bank before picking (100s remaining) // Team had 100s remaining; increment is 30s → DB atomically returns 130s
mockDb.query.draftTimers.findFirst.mockResolvedValue({ mockDb.returning
id: "timer-1", .mockResolvedValueOnce([mockDraftPick])
seasonId: SEASON_ID, .mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
teamId: TEAM_ID,
timeRemaining: 100,
});
await action({ request: defaultPickRequest(), params: {}, context: ctx }); await action({ request: defaultPickRequest(), params: {}, context: ctx });
// 100 + 30 = 130, not 120 (which would be a reset to initialTime) // Verify 130s (100 + 30), not 120s (which would be a flat reset to initialTime)
expect(mockDb.set).toHaveBeenCalledWith( expect(mockSocketIO.emit).toHaveBeenCalledWith(
expect.objectContaining({ timeRemaining: 130 }) "timer-update",
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
); );
}); });
@ -413,13 +410,11 @@ describe("draft.force-manual-pick action", () => {
// leave the next team's timer completely untouched. // leave the next team's timer completely untouched.
it("REGRESSION: does not look up the next team's timer", async () => { it("REGRESSION: does not look up the next team's timer", async () => {
// Before the fix, draftTimers.findFirst was called twice: // The atomic SQL increment (timeRemaining + N) requires no prior read —
// once to get the picking team's current balance, and // draftTimers.findFirst is never called.
// once to find (and then reset) the next team's timer.
// After the fix it should be called exactly once.
await action({ request: defaultPickRequest(), params: {}, context: ctx }); await action({ request: defaultPickRequest(), params: {}, context: ctx });
expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1); expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(0);
}); });
it("REGRESSION: does not emit a timer-update for the next team", async () => { it("REGRESSION: does not emit a timer-update for the next team", async () => {

View file

@ -1,11 +1,12 @@
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -104,11 +105,15 @@ export async function action(args: ActionFunctionArgs) {
}); });
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
const currentRound = Math.ceil(pickNumber / totalTeams); const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((pickNumber - 1) % totalTeams) + 1; // Validate that the submitted teamId is actually the team whose turn it is at pickNumber
if (isEvenRound) { const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
pickInRound = totalTeams - pickInRound + 1; if (!expectedDraftSlot) {
return Response.json({ error: "Invalid draft state" }, { status: 500 });
}
if (expectedDraftSlot.teamId !== teamId) {
return Response.json({ error: "It is not this team's turn to pick at this slot" }, { status: 400 });
} }
// ELIGIBILITY VALIDATION: Check if team can draft from this sport // ELIGIBILITY VALIDATION: Check if team can draft from this sport
@ -156,30 +161,25 @@ export async function action(args: ActionFunctionArgs) {
const totalPicks = totalTeams * season.draftRounds; const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks; const isDraftComplete = nextPickNumber > totalPicks;
// Add increment to the team that just picked // Add increment to the team that just picked (atomic to avoid race with timer loop)
const currentTimer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId)
),
});
const incrementTime = season.draftIncrementTime || 30; const incrementTime = season.draftIncrementTime || 30;
const newTimeRemaining = (currentTimer?.timeRemaining ?? 0) + incrementTime; const [updatedTimer] = await db
if (currentTimer) { .update(schema.draftTimers)
await db .set({
.update(schema.draftTimers) timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
.set({ updatedAt: new Date(),
timeRemaining: newTimeRemaining, })
updatedAt: new Date(), .where(
}) and(
.where(eq(schema.draftTimers.id, currentTimer.id)); eq(schema.draftTimers.seasonId, seasonId),
} else { eq(schema.draftTimers.teamId, teamId)
await db.insert(schema.draftTimers).values({ )
seasonId, )
teamId, .returning();
timeRemaining: newTimeRemaining,
}); const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
if (!updatedTimer) {
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
} }
// Emit timer update to all clients // Emit timer update to all clients
@ -188,7 +188,7 @@ export async function action(args: ActionFunctionArgs) {
seasonId, seasonId,
teamId, teamId,
timeRemaining: newTimeRemaining, timeRemaining: newTimeRemaining,
currentPickNumber: pickNumber, currentPickNumber: nextPickNumber,
}); });
} catch (error) { } catch (error) {
console.error("Socket.IO timer-update error:", error); console.error("Socket.IO timer-update error:", error);
@ -253,14 +253,16 @@ export async function action(args: ActionFunctionArgs) {
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {
const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
await checkAndTriggerNextAutodraft({ if (!freshSeason?.draftPaused) {
seasonId, await checkAndTriggerNextAutodraft({
nextPickNumber, seasonId,
totalTeams, nextPickNumber,
draftSlots, totalTeams,
db, draftSlots,
}); db,
});
}
} }
return Response.json({ return Response.json({

View file

@ -1,11 +1,12 @@
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -55,15 +56,7 @@ export async function action(args: ActionFunctionArgs) {
}); });
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
const currentRound = Math.ceil(currentPickNumber / totalTeams); const { round: currentRound, pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const isEvenRound = currentRound % 2 === 0;
// Calculate which team should pick (snake draft)
let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound); const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
if (!currentDraftSlot) { if (!currentDraftSlot) {
@ -81,7 +74,7 @@ export async function action(args: ActionFunctionArgs) {
const isCommissioner = !!commissionerRecord; const isCommissioner = !!commissionerRecord;
if (!isTeamOwner && !isCommissioner) { if (!isTeamOwner && !isCommissioner) {
return Response.json({ error: "Not your turn to pick" }, { status: 403 }); return Response.json({ error: "You do not have permission to pick for this team" }, { status: 403 });
} }
// Check if participant is already drafted // Check if participant is already drafted
@ -137,6 +130,14 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: reason }, { status: 400 }); return Response.json({ error: reason }, { status: 400 });
} }
// 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)
),
});
// Create the draft pick // Create the draft pick
const [draftPick] = await db const [draftPick] = await db
.insert(schema.draftPicks) .insert(schema.draftPicks)
@ -149,6 +150,7 @@ export async function action(args: ActionFunctionArgs) {
pickInRound, pickInRound,
pickedByUserId: userId, pickedByUserId: userId,
pickedByType: isTeamOwner ? "owner" : "commissioner", pickedByType: isTeamOwner ? "owner" : "commissioner",
timeUsed: timerSnapshot?.timeRemaining ?? 0,
}) })
.returning(); .returning();
@ -176,22 +178,27 @@ export async function action(args: ActionFunctionArgs) {
const totalPicks = totalTeams * season.draftRounds; const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks; const isDraftComplete = nextPickNumber > totalPicks;
// Add increment to the team that just picked (post-pick reward) // Add increment to the team that just picked (post-pick reward).
// Use an atomic SQL update so the timer loop cannot overwrite this increment
// via a concurrent read-modify-write decrement.
const incrementTime = season.draftIncrementTime || 30; const incrementTime = season.draftIncrementTime || 30;
const currentTimer = await db.query.draftTimers.findFirst({ const [updatedTimer] = await db
where: and( .update(schema.draftTimers)
eq(schema.draftTimers.seasonId, seasonId), .set({
eq(schema.draftTimers.teamId, currentDraftSlot.teamId) timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
), updatedAt: new Date(),
}); })
.where(
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
)
)
.returning();
const newTimeRemaining = (currentTimer?.timeRemaining ?? 0) + incrementTime; // Safety fallback: timer row should always exist after draft start
if (currentTimer) { const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
await db if (!updatedTimer) {
.update(schema.draftTimers)
.set({ timeRemaining: newTimeRemaining, updatedAt: new Date() })
.where(eq(schema.draftTimers.id, currentTimer.id));
} else {
await db.insert(schema.draftTimers).values({ await db.insert(schema.draftTimers).values({
seasonId, seasonId,
teamId: currentDraftSlot.teamId, teamId: currentDraftSlot.teamId,
@ -204,7 +211,7 @@ export async function action(args: ActionFunctionArgs) {
seasonId, seasonId,
teamId: currentDraftSlot.teamId, teamId: currentDraftSlot.teamId,
timeRemaining: newTimeRemaining, timeRemaining: newTimeRemaining,
currentPickNumber, currentPickNumber: nextPickNumber,
}); });
} catch (error) { } catch (error) {
console.error("Socket.IO timer-update error:", error); console.error("Socket.IO timer-update error:", error);
@ -247,14 +254,16 @@ export async function action(args: ActionFunctionArgs) {
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {
const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
await checkAndTriggerNextAutodraft({ if (!freshSeason?.draftPaused) {
seasonId, await checkAndTriggerNextAutodraft({
nextPickNumber, seasonId,
totalTeams, nextPickNumber,
draftSlots, totalTeams,
db, draftSlots,
}); db,
});
}
} }
return Response.json({ return Response.json({

View file

@ -2,6 +2,7 @@ import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { deleteSeasonTimers, initializeDraftTimers } from "~/models/draft-timer";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -68,21 +69,13 @@ export async function action(args: ActionFunctionArgs) {
const initialTime = season.draftInitialTime || 120; const initialTime = season.draftInitialTime || 120;
// Delete existing timers for this season // Reset timers for all teams
await db await deleteSeasonTimers(seasonId);
.delete(schema.draftTimers) await initializeDraftTimers(
.where(eq(schema.draftTimers.seasonId, seasonId)); seasonId,
draftSlots.map((slot) => ({ id: slot.teamId })),
// Insert new timers for all teams in a single batch initialTime
await db );
.insert(schema.draftTimers)
.values(
draftSlots.map((slot) => ({
seasonId,
teamId: slot.teamId,
timeRemaining: initialTime,
}))
);
// Emit socket event // Emit socket event
try { try {

View file

@ -166,7 +166,12 @@ export async function loader(args: Route.LoaderArgs) {
: []; : [];
// Load user's team queue if they have a team // Load user's team queue if they have a team
const userQueue = userTeam ? await getTeamQueue(userTeam.id) : []; const userQueue = userTeam
? await getTeamQueue(userTeam.id).catch((err) => {
console.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
return [];
})
: [];
// Load draft timers for all teams // Load draft timers for all teams
const timers = await db.query.draftTimers.findMany({ const timers = await db.query.draftTimers.findMany({

View file

@ -429,9 +429,12 @@ export async function action(args: Route.ActionArgs) {
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null; seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
} }
// Set draft times from speed preset // Set draft times from speed preset (only if draftSpeed was submitted;
seasonUpdates.draftInitialTime = draftInitialTime; // the select is disabled during an active draft so it won't be present)
seasonUpdates.draftIncrementTime = draftIncrementTime; if (draftSpeed !== null) {
seasonUpdates.draftInitialTime = draftInitialTime;
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
// Handle scoring rules (only if in pre_draft status) // Handle scoring rules (only if in pre_draft status)
if (season.status === "pre_draft") { if (season.status === "pre_draft") {
@ -846,8 +849,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Initial time + increment per pick Initial time + increment per pick
</p> </p>
<input type="hidden" name="draftInitialTime" id="draftInitialTime" />
<input type="hidden" name="draftIncrementTime" id="draftIncrementTime" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -1,4 +1,4 @@
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal } from "drizzle-orm/pg-core"; import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
// Users table - synced from Clerk // Users table - synced from Clerk
@ -162,9 +162,12 @@ export const draftPicks = pgTable("draft_picks", {
pickInRound: integer("pick_in_round").notNull(), pickInRound: integer("pick_in_round").notNull(),
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID
pickedByType: pickedByTypeEnum("picked_by_type").notNull(), pickedByType: pickedByTypeEnum("picked_by_type").notNull(),
timeUsed: integer("time_used").notNull().default(0), // seconds timeUsed: integer("time_used").notNull().default(0), // team's time bank (seconds) at the moment the pick was made
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
}); }, (t) => ({
// Prevents two concurrent picks from landing in the same slot
uniquePickSlot: uniqueIndex("draft_picks_season_pick_unique").on(t.seasonId, t.pickNumber),
}));
export const draftQueue = pgTable("draft_queue", { export const draftQueue = pgTable("draft_queue", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),

View file

@ -0,0 +1,2 @@
-- Prevent two concurrent picks from landing in the same draft slot (TOCTOU guard)
CREATE UNIQUE INDEX "draft_picks_season_pick_unique" ON "draft_picks" ("season_id","pick_number");

View file

@ -211,6 +211,13 @@
"when": 1763281000000, "when": 1763281000000,
"tag": "0029_change_participant_ev_to_decimal", "tag": "0029_change_participant_ev_to_decimal",
"breakpoints": true "breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1763282000000,
"tag": "0030_add_draft_picks_unique_slot",
"breakpoints": true
} }
] ]
} }

View file

@ -1,5 +1,21 @@
import { Server as SocketIOServer, Socket } from "socket.io"; import { Server as SocketIOServer, Socket } from "socket.io";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import { drizzle } from "drizzle-orm/postgres-js";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
// Lazy-initialized DB for socket-level validation queries (team ownership checks)
let _socketDb: PostgresJsDatabase<typeof schema> | null = null;
function getSocketDb(): PostgresJsDatabase<typeof schema> {
if (!_socketDb) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is required");
_socketDb = drizzle(postgres(url), { schema });
}
return _socketDb;
}
// Socket event types // Socket event types
interface ServerToClientEvents { interface ServerToClientEvents {
@ -44,7 +60,9 @@ declare global {
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null; let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
// Track connected teams per season // Track connected teams per season (in-memory, single-instance only).
// If the server restarts or runs as multiple instances this map resets.
// For multi-instance deployments, replace with a Redis-backed Socket.IO adapter.
const connectedTeams = new Map<string, Set<string>>(); // seasonId -> Set<teamId> const connectedTeams = new Map<string, Set<string>>(); // seasonId -> Set<teamId>
/** /**
@ -74,7 +92,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
let currentTeamId: string | undefined; let currentTeamId: string | undefined;
let currentSeasonId: string | undefined; let currentSeasonId: string | undefined;
socket.on("join-draft", (seasonId: string, teamId?: string) => { socket.on("join-draft", async (seasonId: string, teamId?: string) => {
if (!seasonId) { if (!seasonId) {
console.error("No seasonId provided for join-draft"); console.error("No seasonId provided for join-draft");
return; return;
@ -83,8 +101,22 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
currentSeasonId = seasonId; currentSeasonId = seasonId;
console.log(`Socket ${socket.id} joined draft-${seasonId}`); console.log(`Socket ${socket.id} joined draft-${seasonId}`);
// If teamId provided, track connection and emit events // If teamId provided, validate it belongs to this season before tracking
if (teamId) { if (teamId) {
try {
const db = getSocketDb();
const team = await db.query.teams.findFirst({
where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)),
});
if (!team) {
console.warn(`[Socket] join-draft rejected: team ${teamId} does not belong to season ${seasonId}`);
return;
}
} catch (err) {
console.error("[Socket] join-draft team validation failed:", err);
return;
}
currentTeamId = teamId; currentTeamId = teamId;
socket.join(`team-${teamId}`); socket.join(`team-${teamId}`);

View file

@ -1,9 +1,10 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, asc } from "drizzle-orm"; import { eq, and, asc, sql } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket"; import { getSocketIO } from "./socket";
import { executeAutoPick } from "~/models/draft-utils"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
// Create a dedicated database connection for the timer // Create a dedicated database connection for the timer
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_URL;
@ -80,16 +81,7 @@ async function updateDraftTimers(): Promise<void> {
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
if (totalTeams === 0) continue; if (totalTeams === 0) continue;
// Calculate current round and pick const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentRound = Math.ceil(currentPickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
// Snake draft: reverse order on even rounds
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const currentDraftSlot = draftSlots.find( const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound (slot) => slot.draftOrder === pickInRound
); );
@ -149,7 +141,7 @@ async function updateDraftTimers(): Promise<void> {
const shouldAutodraft = autodraftSettings?.isEnabled ?? false; const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? autodraftSettings : null); const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) { if (!success) {
console.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`); console.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
@ -160,17 +152,18 @@ async function updateDraftTimers(): Promise<void> {
continue; continue;
} }
// Decrement timer // Atomically decrement timer (race-condition safe: uses DB-level update
const newTimeRemaining = timer.timeRemaining - 1; // so concurrent increments from pick handlers are never overwritten)
const [updatedTimer] = await db
// Update timer in database
await db
.update(schema.draftTimers) .update(schema.draftTimers)
.set({ .set({
timeRemaining: newTimeRemaining, timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.draftTimers.id, timer.id)); .where(eq(schema.draftTimers.id, timer.id))
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
// Emit timer update to all clients in the draft room // Emit timer update to all clients in the draft room
io.to(`draft-${season.id}`).emit("timer-update", { io.to(`draft-${season.id}`).emit("timer-update", {
@ -190,7 +183,7 @@ async function updateDraftTimers(): Promise<void> {
}); });
const shouldAutodraft = autodraftSettings?.isEnabled ?? false; const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? autodraftSettings : null); const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) { if (!success) {
console.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`); console.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
@ -210,7 +203,7 @@ async function triggerAutoPick(
seasonId: string, seasonId: string,
teamId: string, teamId: string,
pickNumber: number, pickNumber: number,
autodraftSettings: any | null autodraftSettings: InferSelectModel<typeof schema.autodraftSettings> | null
): Promise<boolean> { ): Promise<boolean> {
try { try {
const result = await executeAutoPick({ const result = await executeAutoPick({