From 01f14801598de0f5d86ad76f21c3b9ba2244b5cf Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:29:29 -0800 Subject: [PATCH] Claude/fix pick timer ghll n (#29) * Fix force-manual-pick resetting next team's timer to initial time When a commissioner forced a manual pick, the next team's timer was being reset to the initial time (2 minutes) instead of carrying forward their existing time bank balance. This aligns force-manual-pick with the behavior of regular user picks and force-autopick: the picking team gets their increment added, and the next team's timer is left untouched so their bank carries forward. https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p * Add regression tests for draft.force-manual-pick timer behavior 18 tests across 5 describe blocks covering: - Authorization (401/403) - Input validation (missing fields, bad participant, ineligible sport) - Successful pick (response shape, draft-complete detection, socket events) - Timer behavior (increment added to picking team, new timer creation, additive not reset) - Two regression tests confirming the next team's timer is never touched: draftTimers.findFirst called exactly once, no timer-update emitted for next team, db.update called exactly twice (not three times) https://claude.ai/code/session_01X7gwWmafUSEvVHcV7Raz5p * Add TypeScript types and improve draft validation (#28) * Code review fixes: type safety, security hardening, and dead code removal - Fix Socket.IO event types: draft-paused and draft-resumed were typed as () => void but are emitted with { seasonId, paused } data payloads - Fix draft.force-manual-pick: add missing season.status === "draft" guard so commissioners cannot force picks outside an active draft; add duplicate pick-number check so a slot cannot be assigned two picks (the previous code only checked participant uniqueness, not slot uniqueness) - Replace args: any with ActionFunctionArgs / Route.LoaderArgs across all API routes and league loaders; replace (auth as any).userId casts with proper const { userId } = await getAuth(args) destructuring - Remove unused isSnakeDraft = true dead variable from draft.make-pick - Replace autodraftSettings: any and draftSlots: any[] in draft-utils with properly typed InferSelectModel / DraftSlot types - Update force-manual-pick tests: sequence draftPicks.findFirst mock for the two-call flow; add new tests for status-check and slot-uniqueness https://claude.ai/code/session_01FKq2gPFYpgdfxr8cw4Z2AZ * Fix RouterContextProvider type errors in action test files Cast context argument to RouterContextProvider in test helpers so ActionFunctionArgs strict typing is satisfied without weakening the production action signatures back to any. https://claude.ai/code/session_01FKq2gPFYpgdfxr8cw4Z2AZ --------- Co-authored-by: Claude --------- Co-authored-by: Claude --- app/models/draft-utils.ts | 17 +- .../__tests__/draft.force-manual-pick.test.ts | 446 ++++++++++++++++++ app/routes/api/autodraft.update.ts | 6 +- app/routes/api/draft.adjust-time-bank.ts | 6 +- app/routes/api/draft.force-autopick.ts | 6 +- app/routes/api/draft.force-manual-pick.ts | 83 +--- app/routes/api/draft.make-pick.ts | 11 +- app/routes/api/draft.pause.ts | 6 +- app/routes/api/draft.replace-pick.ts | 6 +- app/routes/api/draft.resume.ts | 6 +- app/routes/api/draft.rollback.ts | 6 +- app/routes/api/draft.start.ts | 6 +- app/routes/api/queue.add.ts | 6 +- app/routes/api/queue.clear.ts | 6 +- app/routes/api/queue.remove.ts | 6 +- app/routes/api/queue.reorder.ts | 6 +- .../$leagueId.draft-board.$seasonId.tsx | 6 +- .../leagues/$leagueId.draft.$seasonId.tsx | 6 +- app/routes/leagues/$leagueId.server.ts | 3 +- .../leagues/$leagueId.standings.$seasonId.tsx | 6 +- .../leagues/__tests__/autodraft.test.ts | 17 +- server/socket.ts | 4 +- 22 files changed, 545 insertions(+), 126 deletions(-) create mode 100644 app/routes/api/__tests__/draft.force-manual-pick.test.ts diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 618adda..a8af2dd 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -1,6 +1,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, notInArray, desc, inArray } from "drizzle-orm"; +import type { InferSelectModel } from "drizzle-orm"; import { getTeamQueue } from "./draft-queue"; import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick"; import { getParticipantsForSeasonWithSports } from "./participant"; @@ -12,11 +13,13 @@ import { getSocketIO } from "../../server/socket"; * Check if the next team has autodraft enabled and immediately execute their pick * This is called after a pick is made to chain autodraft picks */ +type DraftSlot = { teamId: string; draftOrder: number }; + export async function checkAndTriggerNextAutodraft(params: { seasonId: string; nextPickNumber: number; totalTeams: number; - draftSlots: any[]; + draftSlots: DraftSlot[]; db?: ReturnType; }): Promise { const { seasonId, totalTeams, draftSlots, db: providedDb } = params; @@ -367,20 +370,26 @@ export function getTeamForPick( * @param params.db - Database instance (optional, will use database() if not provided) * @returns Result object with success status and pick data */ +type AutodraftSettings = InferSelectModel; + export async function executeAutoPick(params: { seasonId: string; teamId: string; pickNumber: number; triggeredBy: "commissioner" | "timer"; commissionerUserId?: string; - autodraftSettings?: any; + autodraftSettings?: AutodraftSettings | null; db?: ReturnType; chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion }): Promise<{ success: boolean; error?: string; - pick?: any; - participant?: any; + pick?: InferSelectModel; + participant?: InferSelectModel & { + sportsSeason: InferSelectModel & { + sport: InferSelectModel; + }; + }; nextPickNumber?: number; isDraftComplete?: boolean; }> { diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts new file mode 100644 index 0000000..d1ea88f --- /dev/null +++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RouterContextProvider } from "react-router"; +import { action } from "~/routes/api/draft.force-manual-pick"; + +const ctx = {} as unknown as RouterContextProvider; + +vi.mock("~/database/context"); +vi.mock("~/server/socket", () => ({ + getSocketIO: vi.fn(), +})); +vi.mock("@clerk/react-router/server", () => ({ + getAuth: vi.fn(), +})); +vi.mock("~/models/draft-pick", () => ({ + getDraftPicksWithSports: vi.fn(), + getTeamDraftPicksWithSports: vi.fn(), +})); +vi.mock("~/models/participant", () => ({ + getParticipantsForSeasonWithSports: vi.fn(), +})); +vi.mock("~/models/season-sport", () => ({ + getSeasonSportsSimple: vi.fn(), +})); +vi.mock("~/lib/draft-eligibility", () => ({ + calculateDraftEligibility: vi.fn(), +})); +vi.mock("~/models/draft-utils", () => ({ + checkAndTriggerNextAutodraft: vi.fn(), +})); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const SEASON_ID = "season-1"; +const TEAM_ID = "team-1"; +const NEXT_TEAM_ID = "team-2"; +const PARTICIPANT_ID = "participant-1"; +const COMMISSIONER_ID = "commissioner-user-1"; +const SPORT_ID = "sport-nfl"; + +const mockSeason = { + id: SEASON_ID, + leagueId: "league-1", + status: "draft", + draftRounds: 3, + draftInitialTime: 120, + draftIncrementTime: 30, + currentPickNumber: 1, + draftPaused: false, +}; + +const mockTeams = [ + { id: TEAM_ID, name: "Team 1", seasonId: SEASON_ID, ownerId: "owner-1" }, + { id: NEXT_TEAM_ID, name: "Team 2", seasonId: SEASON_ID, ownerId: "owner-2" }, +]; + +const mockDraftSlots = [ + { id: "slot-1", seasonId: SEASON_ID, teamId: TEAM_ID, draftOrder: 1, team: mockTeams[0] }, + { id: "slot-2", seasonId: SEASON_ID, teamId: NEXT_TEAM_ID, draftOrder: 2, team: mockTeams[1] }, +]; + +const mockParticipant = { + id: PARTICIPANT_ID, + name: "Patrick Mahomes", + sportsSeason: { + id: "sports-season-1", + sport: { id: SPORT_ID, name: "NFL" }, + }, +}; + +const mockDraftPick = { + id: "pick-1", + seasonId: SEASON_ID, + teamId: TEAM_ID, + participantId: PARTICIPANT_ID, + pickNumber: 1, + round: 1, + pickInRound: 1, + pickedByUserId: COMMISSIONER_ID, + pickedByType: "commissioner", +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeRequest(data: Record) { + const formData = new FormData(); + for (const [key, value] of Object.entries(data)) { + formData.append(key, value); + } + return new Request("http://localhost/api/draft/force-manual-pick", { + method: "POST", + body: formData, + }); +} + +function defaultPickRequest() { + return makeRequest({ + seasonId: SEASON_ID, + teamId: TEAM_ID, + participantId: PARTICIPANT_ID, + pickNumber: "1", + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("draft.force-manual-pick action", () => { + let mockDb: any; + let mockSocketIO: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Auth: default to authenticated commissioner + const { getAuth } = await import("@clerk/react-router/server"); + vi.mocked(getAuth).mockResolvedValue({ userId: COMMISSIONER_ID } as any); + + // Socket + mockSocketIO = { to: vi.fn().mockReturnThis(), emit: vi.fn() }; + const socketModule = await import("~/server/socket"); + vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO); + + // Eligibility model functions + const { getDraftPicksWithSports, getTeamDraftPicksWithSports } = + await import("~/models/draft-pick"); + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]); + + const { getParticipantsForSeasonWithSports } = await import("~/models/participant"); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + + const { getSeasonSportsSimple } = await import("~/models/season-sport"); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + + const { calculateDraftEligibility } = await import("~/lib/draft-eligibility"); + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set([SPORT_ID]), + ineligibleReasons: {}, + } as any); + + const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); + vi.mocked(checkAndTriggerNextAutodraft).mockResolvedValue(undefined); + + // Database — happy-path defaults + mockDb = { + query: { + seasons: { findFirst: vi.fn() }, + commissioners: { findFirst: vi.fn() }, + draftPicks: { findFirst: vi.fn() }, + participants: { findFirst: vi.fn() }, + draftSlots: { findMany: vi.fn() }, + draftTimers: { findFirst: vi.fn() }, + }, + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + returning: vi.fn(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + }; + + mockDb.query.seasons.findFirst.mockResolvedValue(mockSeason); + mockDb.query.commissioners.findFirst.mockResolvedValue({ + id: "c-1", + userId: COMMISSIONER_ID, + }); + mockDb.query.draftPicks.findFirst.mockResolvedValue(null); + mockDb.query.participants.findFirst.mockResolvedValue(mockParticipant); + mockDb.query.draftSlots.findMany.mockResolvedValue(mockDraftSlots); + mockDb.query.draftTimers.findFirst.mockResolvedValue({ + id: "timer-1", + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 75, + }); + mockDb.returning.mockResolvedValue([mockDraftPick]); + + const { database } = await import("~/database/context"); + vi.mocked(database).mockReturnValue(mockDb); + }); + + // ── Authorization ────────────────────────────────────────────────────────── + + describe("authorization", () => { + it("returns 401 when user is not authenticated", async () => { + const { getAuth } = await import("@clerk/react-router/server"); + vi.mocked(getAuth).mockResolvedValue({ userId: null } as any); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(401); + }); + + it("returns 403 when authenticated user is not a commissioner", async () => { + mockDb.query.commissioners.findFirst.mockResolvedValue(null); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(403); + const data = await response.json(); + expect(data.error).toMatch(/commissioner/i); + }); + }); + + // ── Input validation ─────────────────────────────────────────────────────── + + describe("input validation", () => { + it("returns 400 when required fields are missing", async () => { + const response = await action({ + request: makeRequest({ seasonId: SEASON_ID }), // missing teamId, participantId, pickNumber + params: {}, + context: ctx, + }); + + expect(response.status).toBe(400); + }); + + it("returns 404 when the season does not exist", async () => { + mockDb.query.seasons.findFirst.mockResolvedValue(null); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(404); + }); + + it("returns 400 when the draft is not active", async () => { + mockDb.query.seasons.findFirst.mockResolvedValue({ ...mockSeason, status: "pre_draft" }); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toMatch(/not currently active/i); + }); + + it("returns 400 when a pick already exists at the requested slot", async () => { + mockDb.query.draftPicks.findFirst.mockResolvedValueOnce({ + id: "existing-pick", + participantId: "other-participant", + }); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toMatch(/already exists at this slot/i); + }); + + it("returns 400 when the participant is already drafted", async () => { + // First call: slot check (no pick at this slot number) + // Second call: participant check (participant already drafted elsewhere) + mockDb.query.draftPicks.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "existing-pick", + participantId: PARTICIPANT_ID, + }); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toMatch(/already drafted/i); + }); + + it("returns 404 when the participant does not exist", async () => { + mockDb.query.participants.findFirst.mockResolvedValue(null); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(404); + }); + + it("returns 400 when the participant's sport is ineligible for the team", async () => { + const { calculateDraftEligibility } = await import("~/lib/draft-eligibility"); + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set(), + ineligibleReasons: { [SPORT_ID]: "Sport limit reached" }, + } as any); + + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toMatch(/sport limit reached/i); + }); + }); + + // ── Successful pick ──────────────────────────────────────────────────────── + + describe("successful pick", () => { + it("returns 200 with pick data and next pick number", async () => { + const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.pick).toBeDefined(); + expect(data.nextPickNumber).toBe(2); + expect(data.isDraftComplete).toBe(false); + }); + + it("marks the draft as complete when the final pick is made", async () => { + // 2 teams × 3 rounds = 6 total picks; pick 6 is the last + const response = await action({ + request: makeRequest({ + seasonId: SEASON_ID, + teamId: TEAM_ID, + participantId: PARTICIPANT_ID, + pickNumber: "6", + }), + params: {}, + context: ctx, + }); + + const data = await response.json(); + expect(data.isDraftComplete).toBe(true); + }); + + it("emits pick-made to the correct draft room", async () => { + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${SEASON_ID}`); + expect(mockSocketIO.emit).toHaveBeenCalledWith( + "pick-made", + expect.objectContaining({ nextPickNumber: 2, isDraftComplete: false }) + ); + }); + + it("removes the participant from all team queues in the season", async () => { + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockSocketIO.emit).toHaveBeenCalledWith("participant-removed-from-queues", { + participantId: PARTICIPANT_ID, + }); + }); + }); + + // ── Timer behavior ───────────────────────────────────────────────────────── + // + // All three pick paths (user pick, force auto, force manual) must treat timers + // identically: + // 1. The team that was picked FOR gets +draftIncrementTime added to their bank. + // 2. The next team's bank is left completely untouched. + + 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, + }); + + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ 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, + }); + + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockSocketIO.emit).toHaveBeenCalledWith("timer-update", { + seasonId: SEASON_ID, + teamId: TEAM_ID, + timeRemaining: 105, + currentPickNumber: 1, + }); + }); + + it("creates a new timer for the picking team if they have no existing timer", async () => { + mockDb.query.draftTimers.findFirst.mockResolvedValue(null); + + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + // insert should be called for: (1) draft pick, (2) new timer (0 + 30 = 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, + }); + + 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 }) + ); + }); + + // ── REGRESSION TESTS ──────────────────────────────────────────────────── + // + // Bug: force-manual-pick was resetting the next team's timer to + // draftInitialTime (120s) instead of carrying their bank forward. + // The correct behaviour (matching make-pick and force-autopick) is to + // 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. + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockDb.query.draftTimers.findFirst).toHaveBeenCalledTimes(1); + }); + + it("REGRESSION: does not emit a timer-update for the next team", async () => { + // Before the fix, a timer-update was emitted for the next team with + // timeRemaining: draftInitialTime (120s), overwriting their actual bank. + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter( + ([event, payload]: [string, any]) => + event === "timer-update" && payload?.teamId === NEXT_TEAM_ID + ); + expect(nextTeamTimerEmits).toHaveLength(0); + }); + + it("REGRESSION: does not overwrite the next team's existing time bank", async () => { + // Verify that db.update is called exactly twice (timer increment + season + // pick number), not three times (which would include resetting the next + // team's timer). + await action({ request: defaultPickRequest(), params: {}, context: ctx }); + + expect(mockDb.update).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/routes/api/autodraft.update.ts b/app/routes/api/autodraft.update.ts index 6ffb14b..5da956e 100644 --- a/app/routes/api/autodraft.update.ts +++ b/app/routes/api/autodraft.update.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import { getSocketIO } from "~/server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.adjust-time-bank.ts b/app/routes/api/draft.adjust-time-bank.ts index 78246e9..09cc59e 100644 --- a/app/routes/api/draft.adjust-time-bank.ts +++ b/app/routes/api/draft.adjust-time-bank.ts @@ -4,10 +4,10 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts index c5679d7..a2fb8d8 100644 --- a/app/routes/api/draft.force-autopick.ts +++ b/app/routes/api/draft.force-autopick.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import { executeAutoPick } from "~/models/draft-utils"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 03176be..8df1d35 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -7,11 +7,11 @@ import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/d import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSocketIO } from "../../../server/socket"; +import type { ActionFunctionArgs } from "react-router"; -export async function action(args: any) { +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -50,6 +50,22 @@ export async function action(args: any) { return Response.json({ error: "Only commissioners can force a manual pick" }, { status: 403 }); } + if (season.status !== "draft") { + return Response.json({ error: "Draft is not currently active" }, { status: 400 }); + } + + // Check if a pick already exists at this pick number slot (prevents duplicate picks at same slot) + const existingPickAtSlot = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.pickNumber, pickNumber) + ), + }); + + if (existingPickAtSlot) { + return Response.json({ error: "A pick already exists at this slot" }, { status: 400 }); + } + // Check if participant is already drafted const existingPick = await db.query.draftPicks.findFirst({ where: and( @@ -178,65 +194,10 @@ export async function action(args: any) { console.error("Socket.IO timer-update error:", error); } - // Initialize timer for the next team BEFORE updating pick number (prevents race condition) - if (!isDraftComplete) { - // Calculate which team is next using snake draft logic - const nextRound = Math.ceil(nextPickNumber / totalTeams); - const isNextRoundEven = nextRound % 2 === 0; - let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + // Next team's timer is unchanged — their bank carries forward as-is. + // The timer server loop will start counting down from their existing balance. - // Apply snake draft reversal for even rounds - if (isNextRoundEven) { - nextPickInRound = totalTeams - nextPickInRound + 1; - } - - const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); - - if (nextDraftSlot) { - const nextTeamId = nextDraftSlot.teamId; - const initialTime = season.draftInitialTime || 120; - - // Check if timer already exists for next team - const nextTimer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, nextTeamId) - ), - }); - - if (nextTimer) { - // Update existing timer - await db - .update(schema.draftTimers) - .set({ - timeRemaining: initialTime, - updatedAt: new Date(), - }) - .where(eq(schema.draftTimers.id, nextTimer.id)); - } else { - // Create new timer if it doesn't exist - await db.insert(schema.draftTimers).values({ - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - }); - } - - // Emit timer update for next team - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { - seasonId, - teamId: nextTeamId, - timeRemaining: initialTime, - currentPickNumber: nextPickNumber, - }); - } catch (error) { - console.error("Socket.IO next timer-update error:", error); - } - } - } - - // Update season's current pick number (AFTER initializing next timer to prevent race condition) + // Update season's current pick number await db .update(schema.seasons) .set({ diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 9d2bdf8..82c33c9 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -8,10 +8,10 @@ import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -56,12 +56,11 @@ export async function action(args: any) { const totalTeams = draftSlots.length; const currentRound = Math.ceil(currentPickNumber / totalTeams); - const isSnakeDraft = true; // Assuming snake draft const isEvenRound = currentRound % 2 === 0; - // Calculate which team should pick + // Calculate which team should pick (snake draft) let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1; - if (isSnakeDraft && isEvenRound) { + if (isEvenRound) { pickInRound = totalTeams - pickInRound + 1; } diff --git a/app/routes/api/draft.pause.ts b/app/routes/api/draft.pause.ts index d11c9bc..36a3c3d 100644 --- a/app/routes/api/draft.pause.ts +++ b/app/routes/api/draft.pause.ts @@ -4,10 +4,10 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.replace-pick.ts b/app/routes/api/draft.replace-pick.ts index cf47755..2c6de37 100644 --- a/app/routes/api/draft.replace-pick.ts +++ b/app/routes/api/draft.replace-pick.ts @@ -8,10 +8,10 @@ import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getSeasonSportsSimple } from "~/models/season-sport"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.resume.ts b/app/routes/api/draft.resume.ts index 9ecc499..20fd25e 100644 --- a/app/routes/api/draft.resume.ts +++ b/app/routes/api/draft.resume.ts @@ -4,10 +4,10 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.rollback.ts b/app/routes/api/draft.rollback.ts index 9c18dc2..839ff9e 100644 --- a/app/routes/api/draft.rollback.ts +++ b/app/routes/api/draft.rollback.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq, and, gte } from "drizzle-orm"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index b53fdee..7f76321 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq, and } from "drizzle-orm"; import { getSocketIO } from "../../../server/socket"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/queue.add.ts b/app/routes/api/queue.add.ts index 1a4738b..beb70b4 100644 --- a/app/routes/api/queue.add.ts +++ b/app/routes/api/queue.add.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/queue.clear.ts b/app/routes/api/queue.clear.ts index cc3bf4b..b8bc6a5 100644 --- a/app/routes/api/queue.clear.ts +++ b/app/routes/api/queue.clear.ts @@ -3,11 +3,11 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { clearTeamQueue } from "~/models/draft-queue"; +import type { ActionFunctionArgs } from "react-router"; -export async function action(args: any) { +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/queue.remove.ts b/app/routes/api/queue.remove.ts index f5aceb3..bd23b2d 100644 --- a/app/routes/api/queue.remove.ts +++ b/app/routes/api/queue.remove.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { removeFromQueue, getTeamQueue } from "~/models/draft-queue"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/api/queue.reorder.ts b/app/routes/api/queue.reorder.ts index a7accf3..bb09981 100644 --- a/app/routes/api/queue.reorder.ts +++ b/app/routes/api/queue.reorder.ts @@ -4,10 +4,10 @@ import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { reorderQueueWithSeason, getTeamQueue } from "~/models/draft-queue"; -export async function action(args: any) { +import type { ActionFunctionArgs } from "react-router"; +export async function action(args: ActionFunctionArgs) { const { request } = args; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index 2fa61e2..d36ec48 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -7,8 +7,9 @@ import { DraftGrid } from "~/components/DraftGrid"; import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useState, useEffect } from "react"; import { buildOwnerMap } from "~/lib/owner-map"; +import type { Route } from "./+types/$leagueId.draft-board.$seasonId"; -export async function loader(args: any) { +export async function loader(args: Route.LoaderArgs) { const { params } = args; const { leagueId, seasonId } = params; @@ -38,8 +39,7 @@ export async function loader(args: any) { // Check access: public boards are accessible to everyone without auth if (!season.league.isPublicDraftBoard) { // Not public - check if the user is a league member or commissioner - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!userId) { throw new Response("This draft board is not public", { status: 403 }); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 936013d..3f7c64b 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -25,12 +25,12 @@ import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { NotificationSettings } from "~/components/NotificationSettings"; import { toast } from "sonner"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; +import type { Route } from "./+types/$leagueId.draft.$seasonId"; -export async function loader(args: any) { +export async function loader(args: Route.LoaderArgs) { const { params } = args; const { seasonId, leagueId } = params; - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); if (!seasonId) { throw new Response("Season ID is required", { status: 400 }); diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index 8596ee5..ec662c4 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -10,8 +10,9 @@ import { findDraftSlotsBySeasonId, } from "~/models"; import { findCurrentSeasonWithSports } from "~/models/season"; +import type { Route } from "./+types/$leagueId"; -export async function loader(args: any) { +export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); const { params } = args; const { leagueId } = params; diff --git a/app/routes/leagues/$leagueId.standings.$seasonId.tsx b/app/routes/leagues/$leagueId.standings.$seasonId.tsx index fa7893f..7f30705 100644 --- a/app/routes/leagues/$leagueId.standings.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.standings.$seasonId.tsx @@ -9,11 +9,11 @@ import { StandingsTable } from "~/components/standings/StandingsTable"; import { getSevenDayStandingsChange, getSeasonPointProgression } from "~/models/standings"; import { isSeasonComplete, getSeasonCompletionPercentage } from "~/lib/season-helpers.server"; import { PointProgressionChart } from "~/components/standings/PointProgressionChart"; +import type { Route } from "./+types/$leagueId.standings.$seasonId"; -export async function loader(args: any) { +export async function loader(args: Route.LoaderArgs) { try { - const auth = await getAuth(args); - const userId = (auth as any).userId as string | null; + const { userId } = await getAuth(args); const { params } = args; const { leagueId, seasonId } = params; diff --git a/app/routes/leagues/__tests__/autodraft.test.ts b/app/routes/leagues/__tests__/autodraft.test.ts index fa4a886..8eb4896 100644 --- a/app/routes/leagues/__tests__/autodraft.test.ts +++ b/app/routes/leagues/__tests__/autodraft.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RouterContextProvider } from "react-router"; import { action } from "~/routes/api/autodraft.update"; +const ctx = {} as unknown as RouterContextProvider; + // Mock dependencies vi.mock("~/database/context"); vi.mock("~/server/socket", () => ({ @@ -94,7 +97,7 @@ describe("Autodraft Settings API", () => { const response = await action({ request, params: {}, - context: {}, + context: ctx, }); const data = await response.json(); @@ -163,7 +166,7 @@ describe("Autodraft Settings API", () => { const response = await action({ request, params: {}, - context: {}, + context: ctx, }); const data = await response.json(); @@ -206,7 +209,7 @@ describe("Autodraft Settings API", () => { const response = await action({ request, params: {}, - context: {}, + context: ctx, }); expect(response.status).toBe(403); @@ -227,7 +230,7 @@ describe("Autodraft Settings API", () => { const response = await action({ request, params: {}, - context: {}, + context: ctx, }); expect(response.status).toBe(400); @@ -274,7 +277,7 @@ describe("Autodraft Settings API", () => { const response1 = await action({ request: request1, params: {}, - context: {}, + context: ctx, }); const data1 = await response1.json(); @@ -306,7 +309,7 @@ describe("Autodraft Settings API", () => { const response2 = await action({ request: request2, params: {}, - context: {}, + context: ctx, }); const data2 = await response2.json(); @@ -355,7 +358,7 @@ describe("Autodraft Settings API", () => { await action({ request, params: {}, - context: {}, + context: ctx, }); expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`); diff --git a/server/socket.ts b/server/socket.ts index 837f179..7c8b66d 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -26,8 +26,8 @@ interface ServerToClientEvents { "team-connected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void; "connected-teams-list": (data: { teamIds: string[] }) => void; - "draft-paused": () => void; - "draft-resumed": () => void; + "draft-paused": (data: { seasonId: string; paused: boolean }) => void; + "draft-resumed": (data: { seasonId: string; paused: boolean }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void; }