diff --git a/app/models/__tests__/auto-pick.test.ts b/app/models/__tests__/auto-pick.test.ts index f4229f2..e1dc588 100644 --- a/app/models/__tests__/auto-pick.test.ts +++ b/app/models/__tests__/auto-pick.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { autoPickForTeam } from "../draft-utils"; +import { autoPickForTeam, pruneIneligibleQueueItems } from "../draft-utils"; // Mock all external model dependencies so we can control their return values vi.mock("~/models/draft-pick", () => ({ @@ -18,6 +18,7 @@ vi.mock("~/models/season-sport", () => ({ vi.mock("~/models/draft-queue", () => ({ getTeamQueue: vi.fn(), + getAllQueuesForSeason: vi.fn(), })); vi.mock("~/lib/draft-eligibility", () => ({ @@ -35,7 +36,7 @@ import { } from "~/models/draft-pick"; import { getParticipantsForSeasonWithSports } from "~/models/participant"; import { getSeasonSportsSimple } from "~/models/season-sport"; -import { getTeamQueue } from "~/models/draft-queue"; +import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; const SEASON_ID = "season-1"; @@ -217,6 +218,108 @@ describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => { }); }); + describe("ineligible sport — only queued player's sport is full", () => { + it("removes the ineligible queue item and returns null when queueOnly=true", async () => { + // Scenario: team has a snooker player queued but snooker is already full (not in eligibleSportIds) + const queue = [{ id: "q-snooker", participantId: "p-snooker", queuePosition: 1 }]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + // Snooker is NOT eligible — only "sport-other" is + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set(["sport-other"]), + } as ReturnType); + + const mockDelete = vi.fn().mockReturnThis(); + const mockWhere = vi.fn().mockResolvedValue(undefined); + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-snooker", + name: "Ronnie O'Sullivan", + sportsSeason: { sport: { id: "sport-snooker", name: "Snooker" } }, + }, + ]), + }, + }, + delete: mockDelete, + where: mockWhere, + }); + + vi.mocked(isParticipantDrafted).mockResolvedValue(false); // not drafted — ineligible due to sport + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true // queueOnly — no EV fallback + ); + + // Queue item should be removed + expect(mockDelete).toHaveBeenCalled(); + // No valid pick available + expect(result).toBeNull(); + }); + + it("falls back to EV pick from eligible sports when queueOnly=false", async () => { + // Scenario: same ineligible snooker player, but queueOnly is off so system picks best available + const queue = [{ id: "q-snooker", participantId: "p-snooker", queuePosition: 1 }]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set(["sport-other"]), + } as ReturnType); + + // where() call order: + // 1. delete().where() — remove ineligible snooker item from queue + // 2. select().from().where() — getTopAvailableParticipant: drafted picks → [] + // 3. select().from().innerJoin().innerJoin().where() — season sports → [{...}] + // 4. select().from(participants).where().orderBy() — participant query (chain) + const mockDb = { + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-snooker", + name: "Ronnie O'Sullivan", + sportsSeason: { sport: { id: "sport-snooker", name: "Snooker" } }, + }, + ]), + }, + }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn() + .mockResolvedValueOnce(undefined) // call 1: delete ineligible queue items + .mockResolvedValueOnce([]) // call 2: drafted picks + .mockResolvedValueOnce([{ sportsSeasonId: "ss-other", sportId: "sport-other" }]) // call 3: season sports + .mockReturnThis(), // call 4: participant query → chain to orderBy + orderBy: vi.fn().mockResolvedValue([ + { id: "p-ev", name: "EV Player", expectedValue: "90.00" }, + ]), + delete: vi.fn().mockReturnThis(), + }; + + vi.mocked(isParticipantDrafted).mockResolvedValue(false); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + false // queueOnly off — should fall back to best available + ); + + // Should pick the best available from eligible sports, not the snooker player + expect(result).toBe("p-ev"); + }); + }); + describe("partial queue — first item drafted, second available", () => { it("skips drafted item and returns the next valid participant", async () => { const queue = [ @@ -302,3 +405,187 @@ describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => { }); }); }); + +describe("pruneIneligibleQueueItems", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + }); + + // Helper to build the Map returned by getAllQueuesForSeason + function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) { + return new Map(entries.map(([teamId, items]) => [teamId, items as any])); + } + + it("removes queued snooker player when snooker is no longer eligible for a team", async () => { + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([ + { id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } }, + ] as any); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + + // team-1: snooker ineligible; team-2: snooker still eligible + vi.mocked(calculateDraftEligibility) + .mockReturnValueOnce({ eligibleSportIds: new Set([]) } as any) + .mockReturnValueOnce({ eligibleSportIds: new Set(["sport-snooker"]) } as any); + + vi.mocked(getAllQueuesForSeason).mockResolvedValue( + makeSeasonQueues([ + [TEAM_ID, [{ id: "q-1", participantId: "p-snooker", queuePosition: 1 }]], + ["team-2", []], + ]) + ); + + const mockDelete = vi.fn().mockReturnThis(); + const mockWhere = vi.fn().mockResolvedValue(undefined); + const mockDb = makeMockDb({ delete: mockDelete, where: mockWhere }); + + const result = await pruneIneligibleQueueItems({ + seasonId: SEASON_ID, + draftRounds: DRAFT_ROUNDS, + allTeamIds: ALL_TEAM_IDS, + db: mockDb as any, + }); + + expect(result).toHaveLength(1); + expect(result[0].teamId).toBe(TEAM_ID); + expect(result[0].removedParticipantIds).toEqual(["p-snooker"]); + expect(mockDelete).toHaveBeenCalledTimes(1); + }); + + it("returns empty array when all teams still have their sports eligible", async () => { + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([ + { id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } }, + ] as any); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + + vi.mocked(calculateDraftEligibility).mockReturnValue( + { eligibleSportIds: new Set(["sport-snooker"]) } as any + ); + + vi.mocked(getAllQueuesForSeason).mockResolvedValue( + makeSeasonQueues([ + [TEAM_ID, [{ id: "q-1", participantId: "p-snooker", queuePosition: 1 }]], + ["team-2", [{ id: "q-2", participantId: "p-snooker", queuePosition: 1 }]], + ]) + ); + + const mockDelete = vi.fn().mockReturnThis(); + const mockDb = makeMockDb({ delete: mockDelete }); + + const result = await pruneIneligibleQueueItems({ + seasonId: SEASON_ID, + draftRounds: DRAFT_ROUNDS, + allTeamIds: ALL_TEAM_IDS, + db: mockDb as any, + }); + + expect(result).toHaveLength(0); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it("skips teams with empty queues and does not call delete", async () => { + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + vi.mocked(calculateDraftEligibility).mockReturnValue( + { eligibleSportIds: new Set([]) } as any + ); + + // Both teams return no queue items from the single bulk fetch + vi.mocked(getAllQueuesForSeason).mockResolvedValue(new Map()); + + const mockDelete = vi.fn().mockReturnThis(); + const mockDb = makeMockDb({ delete: mockDelete }); + + const result = await pruneIneligibleQueueItems({ + seasonId: SEASON_ID, + draftRounds: DRAFT_ROUNDS, + allTeamIds: ALL_TEAM_IDS, + db: mockDb as any, + }); + + expect(result).toHaveLength(0); + expect(mockDelete).not.toHaveBeenCalled(); + // The bulk fetch replaces per-team queries — getTeamQueue should never be called + expect(vi.mocked(getTeamQueue)).not.toHaveBeenCalled(); + }); + + it("prunes only the ineligible items when a team has a mixed queue", async () => { + // Team has two queued players: snooker (ineligible) and NFL (still eligible) + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([ + { id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } }, + { id: "p-nfl", name: "Patrick Mahomes", sport: { id: "sport-nfl", name: "NFL" } }, + ] as any); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + + vi.mocked(calculateDraftEligibility).mockReturnValue( + { eligibleSportIds: new Set(["sport-nfl"]) } as any // snooker full, NFL still ok + ); + + vi.mocked(getAllQueuesForSeason).mockResolvedValue( + makeSeasonQueues([ + [TEAM_ID, [ + { id: "q-snooker", participantId: "p-snooker", queuePosition: 1 }, + { id: "q-nfl", participantId: "p-nfl", queuePosition: 2 }, + ]], + ]) + ); + + const mockDelete = vi.fn().mockReturnThis(); + const mockWhere = vi.fn().mockResolvedValue(undefined); + const mockDb = makeMockDb({ delete: mockDelete, where: mockWhere }); + + const result = await pruneIneligibleQueueItems({ + seasonId: SEASON_ID, + draftRounds: DRAFT_ROUNDS, + allTeamIds: [TEAM_ID], + db: mockDb as any, + }); + + expect(result).toHaveLength(1); + expect(result[0].removedParticipantIds).toEqual(["p-snooker"]); + // The NFL player must NOT be removed + expect(result[0].removedParticipantIds).not.toContain("p-nfl"); + expect(mockDelete).toHaveBeenCalledTimes(1); + }); + + it("skips a queue item whose participant is not found in season sports and logs a warning", async () => { + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + // allParticipants does NOT include p-mystery + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + vi.mocked(calculateDraftEligibility).mockReturnValue( + { eligibleSportIds: new Set([]) } as any + ); + + vi.mocked(getAllQueuesForSeason).mockResolvedValue( + makeSeasonQueues([ + [TEAM_ID, [{ id: "q-mystery", participantId: "p-mystery", queuePosition: 1 }]], + ]) + ); + + const mockDelete = vi.fn().mockReturnThis(); + const mockDb = makeMockDb({ delete: mockDelete }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const result = await pruneIneligibleQueueItems({ + seasonId: SEASON_ID, + draftRounds: DRAFT_ROUNDS, + allTeamIds: [TEAM_ID], + db: mockDb as any, + }); + + // Item is skipped — not deleted, not returned + expect(result).toHaveLength(0); + expect(mockDelete).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("p-mystery")); + + warnSpy.mockRestore(); + }); +}); diff --git a/app/models/draft-queue.ts b/app/models/draft-queue.ts index 6d947c7..58b062b 100644 --- a/app/models/draft-queue.ts +++ b/app/models/draft-queue.ts @@ -86,6 +86,29 @@ export async function removeParticipantFromQueue(teamId: string, participantId: ); } +export async function getAllQueuesForSeason( + seasonId: string, + providedDb?: ReturnType +): Promise> { + const db = providedDb || database(); + const rows = await db + .select() + .from(schema.draftQueue) + .where(eq(schema.draftQueue.seasonId, seasonId)) + .orderBy(asc(schema.draftQueue.queuePosition)); + + const byTeam = new Map(); + for (const row of rows) { + const existing = byTeam.get(row.teamId); + if (existing) { + existing.push(row); + } else { + byTeam.set(row.teamId, [row]); + } + } + return byTeam; +} + export async function clearAllQueuesForSeason(seasonId: string) { const db = database(); await db diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 520e1ad..22cde54 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -2,7 +2,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; -import { getTeamQueue } from "./draft-queue"; +import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue"; import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick"; import { getParticipantsForSeasonWithSports } from "./participant"; import { getSeasonSportsSimple } from "./season-sport"; @@ -354,6 +354,85 @@ export function getTeamForPick( return sortedOrder[teamIndex]?.teamId || null; } +/** + * After a pick is committed, recalculate draft eligibility for every team and remove + * any queued participants whose sport is no longer eligible for that team. + * + * This handles cases like: a team has a snooker player queued but has now filled their + * last flex slot that snooker could have used — the pick should be proactively removed + * rather than silently failing or pausing the draft later. + * + * Returns the per-team removals so the caller can emit socket events. + */ +export async function pruneIneligibleQueueItems(params: { + seasonId: string; + draftRounds: number; + allTeamIds: string[]; + db: ReturnType; +}): Promise<{ teamId: string; removedParticipantIds: string[] }[]> { + const { seasonId, draftRounds, allTeamIds, db } = params; + + const [allPicks, allParticipants, seasonSports, allQueues] = await Promise.all([ + getDraftPicksWithSports(seasonId, db), + getParticipantsForSeasonWithSports(seasonId, db), + getSeasonSportsSimple(seasonId, db), + getAllQueuesForSeason(seasonId, db), + ]); + + // Build a fast lookup: participantId → sportId + const participantSportMap = new Map(); + for (const p of allParticipants) { + participantSportMap.set(p.id, p.sport.id); + } + + const allTeams = allTeamIds.map((id) => ({ id })); + const results: { teamId: string; removedParticipantIds: string[] }[] = []; + + for (const teamId of allTeamIds) { + const queue = allQueues.get(teamId) ?? []; + if (queue.length === 0) continue; + + const teamPicks = allPicks.filter((p) => p.teamId === teamId); + const eligibility = calculateDraftEligibility( + teamId, + teamPicks, + allPicks, + allParticipants, + seasonSports, + draftRounds, + allTeams + ); + + const ineligible: { id: string; participantId: string }[] = []; + for (const item of queue) { + const sportId = participantSportMap.get(item.participantId); + if (sportId === undefined) { + console.warn( + `[QueuePrune] Team ${teamId}: queue item ${item.id} references participant ${item.participantId} not found in season sports — skipping` + ); + continue; + } + if (!eligibility.eligibleSportIds.has(sportId)) { + ineligible.push({ id: item.id, participantId: item.participantId }); + } + } + + if (ineligible.length > 0) { + await db + .delete(schema.draftQueue) + .where(inArray(schema.draftQueue.id, ineligible.map((i) => i.id))); + + const removedParticipantIds = ineligible.map((i) => i.participantId); + results.push({ teamId, removedParticipantIds }); + console.log( + `[QueuePrune] Team ${teamId}: removed ${ineligible.length} ineligible items (sport no longer eligible)` + ); + } + } + + return results; +} + /** * Execute an autopick for a team - unified function for both commissioner-forced and timer-based autopicks * @@ -618,6 +697,26 @@ export async function executeAutoPick(params: { ) ); + // Proactively prune queue items that are now ineligible due to this pick + // (e.g. a team queued a snooker player but just filled their last flex slot) + try { + const prunedQueues = await pruneIneligibleQueueItems({ + seasonId, + draftRounds: season.draftRounds, + allTeamIds, + db, + }); + const io = getSocketIO(); + for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) { + io.to(`draft-${seasonId}`).emit("queue-eligibility-pruned", { + teamId: prunedTeamId, + removedParticipantIds, + }); + } + } catch (error) { + console.error("[AutoPick] Error pruning ineligible queue items:", error); + } + // Handle autodraft settings for timer-based picks with "next_pick" mode if (triggeredBy === "timer" && autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") { console.log(`[AutoPick] Disabling autodraft for team ${teamId} after next_pick`); diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 1c66928..95492cb 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -6,7 +6,7 @@ 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 { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils"; import { getSocketIO } from "../../../server/socket"; import type { ActionFunctionArgs } from "react-router"; @@ -173,6 +173,26 @@ export async function action(args: ActionFunctionArgs) { console.error("Socket.IO participant-removed-from-queues error:", error); } + // Proactively prune queue items that are now ineligible due to this pick + // (e.g. a team queued a snooker player but just filled their last flex slot) + try { + const allTeamIds = draftSlots.map((slot) => slot.teamId); + const prunedQueues = await pruneIneligibleQueueItems({ + seasonId, + draftRounds: season.draftRounds, + allTeamIds, + db, + }); + for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) { + getSocketIO().to(`draft-${seasonId}`).emit("queue-eligibility-pruned", { + teamId: prunedTeamId, + removedParticipantIds, + }); + } + } catch (error) { + console.error("Queue pruning error after pick:", error); + } + // Calculate next pick info (before updating season) const nextPickNumber = currentPickNumber + 1; const totalPicks = totalTeams * season.draftRounds; diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 77f985f..6996398 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -787,6 +787,12 @@ export default function DraftRoom() { ); }; + const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => { + if (data.teamId !== userTeam?.id) return; + const removed = new Set(data.removedParticipantIds); + setQueue((prev: any) => prev.filter((item: any) => !removed.has(item.participantId))); + }; + const handlePickReplaced = (data: any) => { setPicks((prev: any) => prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p)) @@ -839,6 +845,7 @@ export default function DraftRoom() { on("team-disconnected", handleTeamDisconnected); on("connected-teams-list", handleConnectedTeamsList); on("participant-removed-from-queues", handleParticipantRemovedFromQueues); + on("queue-eligibility-pruned", handleQueueEligibilityPruned); on("pick-replaced", handlePickReplaced); on("draft-rolled-back", handleDraftRolledBack); on("draft-state-sync", handleDraftStateSync); @@ -854,6 +861,7 @@ export default function DraftRoom() { off("team-disconnected", handleTeamDisconnected); off("connected-teams-list", handleConnectedTeamsList); off("participant-removed-from-queues", handleParticipantRemovedFromQueues); + off("queue-eligibility-pruned", handleQueueEligibilityPruned); off("pick-replaced", handlePickReplaced); off("draft-rolled-back", handleDraftRolledBack); off("draft-state-sync", handleDraftStateSync);