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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue