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; }