diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index a8af2dd..a8c8957 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -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); diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts index d1ea88f..d1f5c0a 100644 --- a/app/routes/api/__tests__/draft.force-manual-pick.test.ts +++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts @@ -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 () => { diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 8df1d35..4ef8066 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -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({ diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 82c33c9..1c66928 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -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({ diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index 7f76321..c2d0ed8 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -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 { diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 482124a..68ecf4a 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -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({ diff --git a/app/routes/leagues/$leagueId.settings.tsx b/app/routes/leagues/$leagueId.settings.tsx index 3d4f174..c73a62c 100644 --- a/app/routes/leagues/$leagueId.settings.tsx +++ b/app/routes/leagues/$leagueId.settings.tsx @@ -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

Initial time + increment per pick

- - + diff --git a/database/schema.ts b/database/schema.ts index 0a63a33..cce65b0 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -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(), diff --git a/drizzle/0030_add_draft_picks_unique_slot.sql b/drizzle/0030_add_draft_picks_unique_slot.sql new file mode 100644 index 0000000..7302c41 --- /dev/null +++ b/drizzle/0030_add_draft_picks_unique_slot.sql @@ -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"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f369ca5..5e54837 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/server/socket.ts b/server/socket.ts index 7c8b66d..ac10d9b 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -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 | null = null; +function getSocketDb(): PostgresJsDatabase { + 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 | 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>(); // seasonId -> Set /** @@ -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}`); diff --git a/server/timer.ts b/server/timer.ts index 4dd2cbc..8b0007a 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -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 { 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 { 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 { 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 { }); 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 | null ): Promise { try { const result = await executeAutoPick({