brackt/app/models/draft-utils.ts
Chris Parsons 8e422c6503 fix: Pass database instance to isParticipantDrafted in timer context
The timer system runs outside the request context and uses its own database
  connection. This fix allows isParticipantDrafted to accept an optional db
  parameter so it works in both request handlers and timer contexts.

  Fixes: DatabaseContext not set error during autopick
2025-10-28 23:40:29 -07:00

723 lines
22 KiB
TypeScript

import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, notInArray, desc, inArray } from "drizzle-orm";
import { getTeamQueue } from "./draft-queue";
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
import { getParticipantsForSeasonWithSports } from "./participant";
import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
/**
* 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
*/
export async function checkAndTriggerNextAutodraft(params: {
seasonId: string;
nextPickNumber: number;
totalTeams: number;
draftSlots: any[];
db?: ReturnType<typeof database>;
}): Promise<void> {
const { seasonId, nextPickNumber, totalTeams, draftSlots, db: providedDb } = params;
const db = providedDb || database();
// 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;
// Apply snake draft reversal for even rounds
if (isNextRoundEven) {
nextPickInRound = totalTeams - nextPickInRound + 1;
}
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
if (!nextDraftSlot) return;
const nextTeamId = nextDraftSlot.teamId;
// Check if next team has autodraft enabled
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, nextTeamId)
),
});
if (autodraftSettings?.isEnabled) {
console.log(
`[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${nextPickNumber}`
);
// Immediately execute autopick for this team
await executeAutoPick({
seasonId,
teamId: nextTeamId,
pickNumber: nextPickNumber,
triggeredBy: "timer", // Use "timer" to indicate automatic (not commissioner-forced)
autodraftSettings,
db,
});
} else {
console.log(
`[AutodraftChain] Team ${nextTeamId} does not have autodraft enabled, waiting for manual pick`
);
}
}
/**
* Auto-pick for a team when their timer runs out
* 1. Check queue - pick first eligible item if available (cleans up ineligible items)
* 2. If queue empty, pick highest EV participant not drafted from eligible sports
*
* Updated to respect Omni league draft eligibility rules
*/
export async function autoPickForTeam(
seasonId: string,
teamId: string,
draftRounds: number,
allTeamIds: string[],
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Calculate eligibility for this team
const allPicks = await getDraftPicksWithSports(seasonId, db);
const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId, db);
const allParticipants = await getParticipantsForSeasonWithSports(seasonId, db);
const seasonSports = await getSeasonSportsSimple(seasonId, db);
const allTeams = allTeamIds.map((id) => ({ id }));
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
draftRounds,
allTeams
);
console.log(
`[AutoPick] Team ${teamId} eligible sports:`,
Array.from(eligibility.eligibleSportIds)
);
// Check queue first - filter by eligible sports
const queue = await getTeamQueue(teamId, db);
if (queue.length > 0) {
console.log(`[AutoPick] Team ${teamId} has ${queue.length} items in queue`);
// Get participant details for queue items to check eligibility
const queueParticipantIds = queue.map((item) => item.participantId);
const queueParticipants = await db.query.participants.findMany({
where: inArray(schema.participants.id, queueParticipantIds),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
const ineligibleQueueItemIds: string[] = [];
// Try queue items in order, checking both drafted status and sport eligibility
for (const item of queue) {
const participant = queueParticipants.find((p) => p.id === item.participantId);
if (!participant) {
console.log(`[AutoPick] Queue item ${item.id} - participant not found, will remove`);
ineligibleQueueItemIds.push(item.id);
continue;
}
const sportId = participant.sportsSeason.sport.id;
const isEligible = eligibility.eligibleSportIds.has(sportId);
const isDrafted = await isParticipantDrafted(seasonId, item.participantId, db);
if (isDrafted) {
console.log(
`[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - already drafted, will remove`
);
ineligibleQueueItemIds.push(item.id);
continue;
}
if (!isEligible) {
console.log(
`[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - not eligible for this team, will remove`
);
ineligibleQueueItemIds.push(item.id);
continue;
}
// Found a valid pick from queue
console.log(
`[AutoPick] Selecting from queue: ${participant.name} (${participant.sportsSeason.sport.name})`
);
// Clean up ineligible items from queue before returning
if (ineligibleQueueItemIds.length > 0) {
await db
.delete(schema.draftQueue)
.where(inArray(schema.draftQueue.id, ineligibleQueueItemIds));
console.log(`[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue`);
}
return item.participantId;
}
// All queue items were ineligible or drafted - clean them up
if (ineligibleQueueItemIds.length > 0) {
await db
.delete(schema.draftQueue)
.where(inArray(schema.draftQueue.id, ineligibleQueueItemIds));
console.log(
`[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue (all items were invalid)`
);
}
}
// Queue is empty or all queued players drafted/ineligible
// Pick highest EV available from eligible sports
console.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`);
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds, db);
}
/**
* Get the highest EV participant that hasn't been drafted yet
* Updated to filter by eligible sports
*/
export async function getTopAvailableParticipant(
seasonId: string,
eligibleSportIds?: Set<string>,
providedDb?: ReturnType<typeof database>
) {
const db = providedDb || database();
// Get all drafted participant IDs
const draftedPicks = await db
.select({ participantId: schema.draftPicks.participantId })
.from(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId));
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
// Get all participants from season sports, filtered by eligible sports if provided
let seasonSportsData;
if (eligibleSportIds && eligibleSportIds.size > 0) {
// Filter to only eligible sports
seasonSportsData = await db
.select({
sportsSeasonId: schema.seasonSports.sportsSeasonId,
sportId: schema.sports.id,
})
.from(schema.seasonSports)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonSports.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.seasonSports.seasonId, seasonId));
// Filter to only eligible sports
seasonSportsData = seasonSportsData.filter((s: { sportId: string }) =>
eligibleSportIds.has(s.sportId)
);
} else {
// No filtering - get all sports
seasonSportsData = await db
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
.from(schema.seasonSports)
.where(eq(schema.seasonSports.seasonId, seasonId));
}
const sportsSeasonIds = seasonSportsData.map(
(s: { sportsSeasonId: string }) => s.sportsSeasonId
);
if (sportsSeasonIds.length === 0) {
return null;
}
// Get top available participant by EV
let query = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]))
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
// Filter out drafted participants if any exist
if (draftedIds.length > 0) {
query = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]),
notInArray(schema.participants.id, draftedIds)
)
)
.orderBy(desc(schema.participants.expectedValue), schema.participants.name);
}
// Handle multiple sports seasons
if (sportsSeasonIds.length > 1) {
// For simplicity, we'll query all and sort in memory
// In production, might want to optimize this
const allParticipants = [];
for (const sportsSeasonId of sportsSeasonIds) {
let participantQuery = db
.select()
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (draftedIds.length > 0) {
participantQuery = db
.select()
.from(schema.participants)
.where(
and(
eq(schema.participants.sportsSeasonId, sportsSeasonId),
notInArray(schema.participants.id, draftedIds)
)
);
}
const seasonParticipants = await participantQuery;
allParticipants.push(...seasonParticipants);
}
// Sort by EV desc, then name
allParticipants.sort((a, b) => {
if (b.expectedValue !== a.expectedValue) {
return b.expectedValue - a.expectedValue;
}
return a.name.localeCompare(b.name);
});
return allParticipants[0]?.id || null;
}
const [topParticipant] = await query;
return topParticipant?.id || null;
}
/**
* Calculate the current pick based on draft order and round
*/
export function calculatePickInfo(
pickNumber: number,
teamCount: number
): { round: number; pickInRound: number; teamIndex: number } {
const round = Math.ceil(pickNumber / teamCount);
const pickInRound = ((pickNumber - 1) % teamCount) + 1;
// Snake draft: odd rounds go forward, even rounds go backward
const isOddRound = round % 2 === 1;
const teamIndex = isOddRound ? pickInRound - 1 : teamCount - pickInRound;
return { round, pickInRound, teamIndex };
}
/**
* Get the team ID for a given pick number based on draft order
*/
export function getTeamForPick(
pickNumber: number,
draftOrder: { teamId: string; draftOrder: number }[]
): string | null {
const sortedOrder = [...draftOrder].sort((a, b) => a.draftOrder - b.draftOrder);
const teamCount = sortedOrder.length;
if (teamCount === 0) return null;
const { teamIndex } = calculatePickInfo(pickNumber, teamCount);
return sortedOrder[teamIndex]?.teamId || null;
}
/**
* Execute an autopick for a team - unified function for both commissioner-forced and timer-based autopicks
*
* Selection Logic:
* 1. Uses the team's draft queue first (prioritizes manager's preferences)
* 2. Validates each queued participant for:
* - Not already drafted
* - Eligible based on draft rules (sport eligibility, flex spots, etc.)
* 3. Automatically removes ineligible participants from queue
* 4. If queue is empty or all items are ineligible, selects highest EV participant from eligible sports
*
* This ensures autopicks ALWAYS respect draft eligibility rules and cannot make illegal selections.
*
* @param params.seasonId - The season ID
* @param params.teamId - The team making the pick
* @param params.pickNumber - The current pick number
* @param params.triggeredBy - Who/what triggered the autopick ("commissioner" or "timer")
* @param params.commissionerUserId - User ID of commissioner (required if triggeredBy is "commissioner")
* @param params.autodraftSettings - Autodraft settings (used for timer-based picks)
* @param params.db - Database instance (optional, will use database() if not provided)
* @returns Result object with success status and pick data
*/
export async function executeAutoPick(params: {
seasonId: string;
teamId: string;
pickNumber: number;
triggeredBy: "commissioner" | "timer";
commissionerUserId?: string;
autodraftSettings?: any;
db?: ReturnType<typeof database>;
}): Promise<{
success: boolean;
error?: string;
pick?: any;
participant?: any;
nextPickNumber?: number;
isDraftComplete?: boolean;
}> {
const {
seasonId,
teamId,
pickNumber,
triggeredBy,
commissionerUserId,
autodraftSettings,
db: providedDb,
} = params;
const db = providedDb || database();
try {
// Race condition protection - check if pick already made
const existingPick = await db.query.draftPicks.findFirst({
where: and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
),
});
if (existingPick) {
console.log(`[AutoPick] Pick ${pickNumber} already made, skipping`);
return {
success: false,
error: "Pick already made",
};
}
// Get season details
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return {
success: false,
error: "Season not found",
};
}
// Get draft slots to calculate round/pickInRound and get all team IDs
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: schema.draftSlots.draftOrder,
with: {
team: true,
},
});
const totalTeams = draftSlots.length;
if (totalTeams === 0) {
return {
success: false,
error: "No draft slots found",
};
}
const allTeamIds = draftSlots.map((slot) => slot.teamId);
// Use autoPickForTeam to select participant (respects eligibility and queue)
const participantId = await autoPickForTeam(
seasonId,
teamId,
season.draftRounds,
allTeamIds,
db
);
if (!participantId) {
return {
success: false,
error: "No eligible participants available to pick",
};
}
// Get participant details
const participantToPick = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
if (!participantToPick) {
return {
success: false,
error: "Participant not found",
};
}
// Calculate round and pickInRound with snake draft logic
const currentRound = Math.ceil(pickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
// Apply snake draft reversal for even rounds
if (isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
// Determine pickedByUserId based on trigger
const pickedByUserId = triggeredBy === "commissioner"
? (commissionerUserId || "")
: "";
// Create the draft pick
const [draftPick] = await db
.insert(schema.draftPicks)
.values({
seasonId,
teamId,
participantId: participantToPick.id,
pickNumber,
round: currentRound,
pickInRound,
pickedByUserId,
pickedByType: "auto",
timeUsed: triggeredBy === "timer" ? (season.draftInitialTime || 120) : undefined,
})
.returning();
console.log(
`[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}`
);
// Calculate next pick info (before updating season)
const nextPickNumber = pickNumber + 1;
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
// Add increment to the team that just picked
const currentTimer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId)
),
});
if (currentTimer) {
const incrementTime = season.draftIncrementTime || 30;
const newTimeRemaining = currentTimer.timeRemaining + incrementTime;
await db
.update(schema.draftTimers)
.set({
timeRemaining: newTimeRemaining,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, currentTimer.id));
// Emit timer update to all clients
try {
const io = (global as any).__socketIO;
if (io) {
io.to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId,
timeRemaining: newTimeRemaining,
currentPickNumber: pickNumber,
});
}
} catch (error) {
console.error("[AutoPick] 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;
// 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;
console.log(
`[AutoPick] Initializing timer for next team ${nextTeamId} with ${initialTime}s (pick ${nextPickNumber})`
);
// 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 {
const io = (global as any).__socketIO;
if (io) {
io.to(`draft-${seasonId}`).emit("timer-update", {
seasonId,
teamId: nextTeamId,
timeRemaining: initialTime,
currentPickNumber: nextPickNumber,
});
}
} catch (error) {
console.error("[AutoPick] Socket.IO next timer-update error:", error);
}
}
}
// Update season's current pick number (AFTER initializing next timer to prevent race condition)
await db
.update(schema.seasons)
.set({
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
status: isDraftComplete ? "active" : season.status,
})
.where(eq(schema.seasons.id, seasonId));
// Remove from ALL team queues in this season (participant is now drafted)
await db
.delete(schema.draftQueue)
.where(
and(
eq(schema.draftQueue.seasonId, seasonId),
eq(schema.draftQueue.participantId, participantToPick.id)
)
);
// 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`);
await db
.update(schema.autodraftSettings)
.set({
isEnabled: false,
updatedAt: new Date(),
})
.where(eq(schema.autodraftSettings.id, autodraftSettings.id));
// Emit autodraft-updated event
try {
const io = (global as any).__socketIO;
if (io) {
io.to(`draft-${seasonId}`).emit("autodraft-updated", {
teamId,
isEnabled: false,
mode: autodraftSettings.mode,
});
}
} catch (error) {
console.error("[AutoPick] Socket.IO autodraft-updated error:", error);
}
}
// Emit socket events
try {
const io = (global as any).__socketIO;
if (io) {
const team = draftSlots.find((slot) => slot.team.id === teamId)?.team;
// Emit participant-removed-from-queues event
io.to(`draft-${seasonId}`).emit("participant-removed-from-queues", {
participantId: participantToPick.id,
});
// Emit pick-made event
io.to(`draft-${seasonId}`).emit("pick-made", {
pick: {
...draftPick,
team,
participant: {
...participantToPick,
sport: participantToPick.sportsSeason.sport,
},
sport: participantToPick.sportsSeason.sport,
},
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
isDraftComplete,
});
// Emit draft-completed event if applicable
if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed");
}
}
} catch (error) {
console.error("[AutoPick] Socket.IO events error:", error);
}
// Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) {
await checkAndTriggerNextAutodraft({
seasonId,
nextPickNumber,
totalTeams,
draftSlots,
db,
});
}
return {
success: true,
pick: draftPick,
participant: participantToPick,
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
isDraftComplete,
};
} catch (error) {
console.error("[AutoPick] Error in executeAutoPick:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}