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:
parent
fd46e4f0b5
commit
f33e39264d
12 changed files with 234 additions and 191 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { database } from "~/database/context";
|
||||
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 { getTeamQueue } from "./draft-queue";
|
||||
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
|
||||
|
|
@ -27,17 +27,15 @@ export async function checkAndTriggerNextAutodraft(params: {
|
|||
|
||||
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
|
||||
while (true) {
|
||||
// Calculate which team is next using snake draft logic
|
||||
const nextRound = Math.ceil(currentPickNumber / totalTeams);
|
||||
const isNextRoundEven = nextRound % 2 === 0;
|
||||
let nextPickInRound = ((currentPickNumber - 1) % totalTeams) + 1;
|
||||
|
||||
if (isNextRoundEven) {
|
||||
nextPickInRound = totalTeams - nextPickInRound + 1;
|
||||
}
|
||||
|
||||
while (iterations < maxIterations) {
|
||||
iterations++;
|
||||
const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
|
||||
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(
|
||||
pickNumber: number,
|
||||
teamCount: number
|
||||
): { round: number; pickInRound: number; teamIndex: number } {
|
||||
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
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +437,7 @@ export async function executeAutoPick(params: {
|
|||
// Get draft slots to calculate round/pickInRound and get all team IDs
|
||||
const draftSlots = await db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||
orderBy: schema.draftSlots.draftOrder,
|
||||
orderBy: asc(schema.draftSlots.draftOrder),
|
||||
with: {
|
||||
team: true,
|
||||
},
|
||||
|
|
@ -488,15 +488,7 @@ export async function executeAutoPick(params: {
|
|||
};
|
||||
}
|
||||
|
||||
// Calculate round and pickInRound with snake draft logic
|
||||
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;
|
||||
}
|
||||
const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams);
|
||||
|
||||
// Determine pickedByUserId based on trigger
|
||||
const pickedByUserId = triggeredBy === "commissioner"
|
||||
|
|
@ -542,24 +534,32 @@ export async function executeAutoPick(params: {
|
|||
const totalPicks = totalTeams * season.draftRounds;
|
||||
const isDraftComplete = nextPickNumber > totalPicks;
|
||||
|
||||
// Add increment to the team that just picked (post-pick reward)
|
||||
if (currentTimer) {
|
||||
const newTimeRemaining = currentTimer.timeRemaining + incrementTime;
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: newTimeRemaining, updatedAt: new Date() })
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
// Add increment to the team that just picked (post-pick reward).
|
||||
// Atomic update so the timer loop cannot overwrite this via a concurrent decrement.
|
||||
const [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, teamId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (updatedTimer) {
|
||||
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 {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
currentPickNumber: pickNumber,
|
||||
timeRemaining: updatedTimer.timeRemaining,
|
||||
currentPickNumber: nextPickNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AutoPick] Socket.IO timer-update error:", error);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ vi.mock("~/lib/draft-eligibility", () => ({
|
|||
}));
|
||||
vi.mock("~/models/draft-utils", () => ({
|
||||
checkAndTriggerNextAutodraft: vi.fn(),
|
||||
calculatePickInfo: vi.fn().mockReturnValue({ round: 1, pickInRound: 1, teamIndex: 0 }),
|
||||
}));
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -346,28 +347,23 @@ describe("draft.force-manual-pick action", () => {
|
|||
|
||||
describe("timer behavior", () => {
|
||||
it("adds draftIncrementTime to the picking team's time bank", async () => {
|
||||
// Team has 75s; increment is 30s → expected new balance: 105s
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({
|
||||
id: "timer-1",
|
||||
seasonId: SEASON_ID,
|
||||
teamId: TEAM_ID,
|
||||
timeRemaining: 75,
|
||||
});
|
||||
// Timer has 75s; increment is 30s → DB atomically returns new balance: 105s
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||
|
||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 105 })
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||
);
|
||||
});
|
||||
|
||||
it("emits timer-update for the picking team with their incremented balance", async () => {
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({
|
||||
id: "timer-1",
|
||||
seasonId: SEASON_ID,
|
||||
teamId: TEAM_ID,
|
||||
timeRemaining: 75,
|
||||
});
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||
|
||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||
|
||||
|
|
@ -375,33 +371,34 @@ describe("draft.force-manual-pick action", () => {
|
|||
seasonId: SEASON_ID,
|
||||
teamId: TEAM_ID,
|
||||
timeRemaining: 105,
|
||||
currentPickNumber: 1,
|
||||
currentPickNumber: 2,
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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)
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({
|
||||
id: "timer-1",
|
||||
seasonId: SEASON_ID,
|
||||
teamId: TEAM_ID,
|
||||
timeRemaining: 100,
|
||||
});
|
||||
// Team had 100s remaining; increment is 30s → DB atomically returns 130s
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 130 }]);
|
||||
|
||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||
|
||||
// 100 + 30 = 130, not 120 (which would be a reset to initialTime)
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 130 })
|
||||
// Verify 130s (100 + 30), not 120s (which would be a flat reset to initialTime)
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"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.
|
||||
|
||||
it("REGRESSION: does not look up the next team's timer", async () => {
|
||||
// Before the fix, draftTimers.findFirst was called twice:
|
||||
// once to get the picking team's current balance, and
|
||||
// once to find (and then reset) the next team's timer.
|
||||
// After the fix it should be called exactly once.
|
||||
// The atomic SQL increment (timeRemaining + N) requires no prior read —
|
||||
// draftTimers.findFirst is never called.
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
||||
|
|
@ -104,11 +105,15 @@ export async function action(args: ActionFunctionArgs) {
|
|||
});
|
||||
|
||||
const totalTeams = draftSlots.length;
|
||||
const currentRound = Math.ceil(pickNumber / totalTeams);
|
||||
const isEvenRound = currentRound % 2 === 0;
|
||||
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
|
||||
if (isEvenRound) {
|
||||
pickInRound = totalTeams - pickInRound + 1;
|
||||
const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams);
|
||||
|
||||
// Validate that the submitted teamId is actually the team whose turn it is at pickNumber
|
||||
const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
|
||||
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
|
||||
|
|
@ -156,30 +161,25 @@ export async function action(args: ActionFunctionArgs) {
|
|||
const totalPicks = totalTeams * season.draftRounds;
|
||||
const isDraftComplete = nextPickNumber > totalPicks;
|
||||
|
||||
// Add increment to the team that just picked
|
||||
const currentTimer = await db.query.draftTimers.findFirst({
|
||||
where: and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, teamId)
|
||||
),
|
||||
});
|
||||
|
||||
// Add increment to the team that just picked (atomic to avoid race with timer loop)
|
||||
const incrementTime = season.draftIncrementTime || 30;
|
||||
const newTimeRemaining = (currentTimer?.timeRemaining ?? 0) + incrementTime;
|
||||
if (currentTimer) {
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({
|
||||
timeRemaining: newTimeRemaining,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
} else {
|
||||
await db.insert(schema.draftTimers).values({
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
});
|
||||
const [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, teamId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||
}
|
||||
|
||||
// Emit timer update to all clients
|
||||
|
|
@ -188,7 +188,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
currentPickNumber: pickNumber,
|
||||
currentPickNumber: nextPickNumber,
|
||||
});
|
||||
} catch (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
|
||||
if (!isDraftComplete) {
|
||||
const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils");
|
||||
await checkAndTriggerNextAutodraft({
|
||||
seasonId,
|
||||
nextPickNumber,
|
||||
totalTeams,
|
||||
draftSlots,
|
||||
db,
|
||||
});
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
await checkAndTriggerNextAutodraft({
|
||||
seasonId,
|
||||
nextPickNumber,
|
||||
totalTeams,
|
||||
draftSlots,
|
||||
db,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
|
@ -55,15 +56,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
});
|
||||
|
||||
const totalTeams = draftSlots.length;
|
||||
const currentRound = Math.ceil(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 { round: currentRound, pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||
const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
|
||||
|
||||
if (!currentDraftSlot) {
|
||||
|
|
@ -81,7 +74,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
const isCommissioner = !!commissionerRecord;
|
||||
|
||||
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
|
||||
|
|
@ -137,6 +130,14 @@ export async function action(args: ActionFunctionArgs) {
|
|||
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
|
||||
const [draftPick] = await db
|
||||
.insert(schema.draftPicks)
|
||||
|
|
@ -149,6 +150,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
pickInRound,
|
||||
pickedByUserId: userId,
|
||||
pickedByType: isTeamOwner ? "owner" : "commissioner",
|
||||
timeUsed: timerSnapshot?.timeRemaining ?? 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -176,22 +178,27 @@ export async function action(args: ActionFunctionArgs) {
|
|||
const totalPicks = totalTeams * season.draftRounds;
|
||||
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 currentTimer = await db.query.draftTimers.findFirst({
|
||||
where: and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
||||
),
|
||||
});
|
||||
const [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();
|
||||
|
||||
const newTimeRemaining = (currentTimer?.timeRemaining ?? 0) + incrementTime;
|
||||
if (currentTimer) {
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: newTimeRemaining, updatedAt: new Date() })
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
} else {
|
||||
// Safety fallback: timer row should always exist after draft start
|
||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({
|
||||
seasonId,
|
||||
teamId: currentDraftSlot.teamId,
|
||||
|
|
@ -204,7 +211,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
seasonId,
|
||||
teamId: currentDraftSlot.teamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
currentPickNumber,
|
||||
currentPickNumber: nextPickNumber,
|
||||
});
|
||||
} catch (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
|
||||
if (!isDraftComplete) {
|
||||
const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils");
|
||||
await checkAndTriggerNextAutodraft({
|
||||
seasonId,
|
||||
nextPickNumber,
|
||||
totalTeams,
|
||||
draftSlots,
|
||||
db,
|
||||
});
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
await checkAndTriggerNextAutodraft({
|
||||
seasonId,
|
||||
nextPickNumber,
|
||||
totalTeams,
|
||||
draftSlots,
|
||||
db,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getAuth } from "@clerk/react-router/server";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { deleteSeasonTimers, initializeDraftTimers } from "~/models/draft-timer";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
|
@ -68,21 +69,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
|
||||
const initialTime = season.draftInitialTime || 120;
|
||||
|
||||
// Delete existing timers for this season
|
||||
await db
|
||||
.delete(schema.draftTimers)
|
||||
.where(eq(schema.draftTimers.seasonId, seasonId));
|
||||
|
||||
// Insert new timers for all teams in a single batch
|
||||
await db
|
||||
.insert(schema.draftTimers)
|
||||
.values(
|
||||
draftSlots.map((slot) => ({
|
||||
seasonId,
|
||||
teamId: slot.teamId,
|
||||
timeRemaining: initialTime,
|
||||
}))
|
||||
);
|
||||
// Reset timers for all teams
|
||||
await deleteSeasonTimers(seasonId);
|
||||
await initializeDraftTimers(
|
||||
seasonId,
|
||||
draftSlots.map((slot) => ({ id: slot.teamId })),
|
||||
initialTime
|
||||
);
|
||||
|
||||
// Emit socket event
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,12 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
: [];
|
||||
|
||||
// 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
|
||||
const timers = await db.query.draftTimers.findMany({
|
||||
|
|
|
|||
|
|
@ -429,9 +429,12 @@ export async function action(args: Route.ActionArgs) {
|
|||
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
||||
}
|
||||
|
||||
// Set draft times from speed preset
|
||||
seasonUpdates.draftInitialTime = draftInitialTime;
|
||||
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
||||
// Set draft times from speed preset (only if draftSpeed was submitted;
|
||||
// the select is disabled during an active draft so it won't be present)
|
||||
if (draftSpeed !== null) {
|
||||
seasonUpdates.draftInitialTime = draftInitialTime;
|
||||
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
||||
}
|
||||
|
||||
// Handle scoring rules (only if in pre_draft status)
|
||||
if (season.status === "pre_draft") {
|
||||
|
|
@ -846,8 +849,7 @@ export default function LeagueSettings({ loaderData, actionData }: Route.Compone
|
|||
<p className="text-sm text-muted-foreground">
|
||||
Initial time + increment per pick
|
||||
</p>
|
||||
<input type="hidden" name="draftInitialTime" id="draftInitialTime" />
|
||||
<input type="hidden" name="draftIncrementTime" id="draftIncrementTime" />
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
// Users table - synced from Clerk
|
||||
|
|
@ -162,9 +162,12 @@ export const draftPicks = pgTable("draft_picks", {
|
|||
pickInRound: integer("pick_in_round").notNull(),
|
||||
pickedByUserId: varchar("picked_by_user_id", { length: 255 }).notNull(), // Clerk user ID
|
||||
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(),
|
||||
});
|
||||
}, (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", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
|
|
|||
2
drizzle/0030_add_draft_picks_unique_slot.sql
Normal file
2
drizzle/0030_add_draft_picks_unique_slot.sql
Normal 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");
|
||||
|
|
@ -211,6 +211,13 @@
|
|||
"when": 1763281000000,
|
||||
"tag": "0029_change_participant_ev_to_decimal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1763282000000,
|
||||
"tag": "0030_add_draft_picks_unique_slot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,5 +1,21 @@
|
|||
import { Server as SocketIOServer, Socket } from "socket.io";
|
||||
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
|
||||
interface ServerToClientEvents {
|
||||
|
|
@ -44,7 +60,9 @@ declare global {
|
|||
|
||||
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>
|
||||
|
||||
/**
|
||||
|
|
@ -74,7 +92,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
let currentTeamId: 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) {
|
||||
console.error("No seasonId provided for join-draft");
|
||||
return;
|
||||
|
|
@ -83,8 +101,22 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
currentSeasonId = 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) {
|
||||
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;
|
||||
socket.join(`team-${teamId}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
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 { executeAutoPick } from "~/models/draft-utils";
|
||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||
|
||||
// Create a dedicated database connection for the timer
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
|
@ -80,16 +81,7 @@ async function updateDraftTimers(): Promise<void> {
|
|||
const totalTeams = draftSlots.length;
|
||||
if (totalTeams === 0) continue;
|
||||
|
||||
// Calculate current round and pick
|
||||
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 { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||
const currentDraftSlot = draftSlots.find(
|
||||
(slot) => slot.draftOrder === pickInRound
|
||||
);
|
||||
|
|
@ -149,7 +141,7 @@ async function updateDraftTimers(): Promise<void> {
|
|||
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Decrement timer
|
||||
const newTimeRemaining = timer.timeRemaining - 1;
|
||||
|
||||
// Update timer in database
|
||||
await db
|
||||
// Atomically decrement timer (race-condition safe: uses DB-level update
|
||||
// so concurrent increments from pick handlers are never overwritten)
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({
|
||||
timeRemaining: newTimeRemaining,
|
||||
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
|
||||
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
|
||||
io.to(`draft-${season.id}`).emit("timer-update", {
|
||||
|
|
@ -190,7 +183,7 @@ async function updateDraftTimers(): Promise<void> {
|
|||
});
|
||||
|
||||
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) {
|
||||
console.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
|
||||
|
|
@ -210,7 +203,7 @@ async function triggerAutoPick(
|
|||
seasonId: string,
|
||||
teamId: string,
|
||||
pickNumber: number,
|
||||
autodraftSettings: any | null
|
||||
autodraftSettings: InferSelectModel<typeof schema.autodraftSettings> | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await executeAutoPick({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue