- Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn
879 lines
30 KiB
TypeScript
879 lines
30 KiB
TypeScript
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm";
|
|
import { logger } from "~/lib/logger";
|
|
import type { InferSelectModel } from "drizzle-orm";
|
|
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
|
|
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
|
|
import { getParticipantsForSeasonWithSports } from "./season-participant";
|
|
import { getSeasonSportsSimple } from "./season-sport";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
|
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
|
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
|
|
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
|
|
|
/**
|
|
* 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: DraftSlot[];
|
|
db?: ReturnType<typeof database>;
|
|
}): Promise<void> {
|
|
const { seasonId, totalTeams, draftSlots, db: providedDb } = params;
|
|
const db = providedDb || database();
|
|
|
|
let currentPickNumber = params.nextPickNumber;
|
|
|
|
// Cap iterations at totalTeams: in the worst case every team has autodraft enabled,
|
|
// so we make at most totalTeams consecutive picks before handing back to the timer loop.
|
|
const maxIterations = params.totalTeams;
|
|
let iterations = 0;
|
|
|
|
// Iteratively execute autodraft picks for consecutive teams with autodraft enabled
|
|
while (iterations < maxIterations) {
|
|
iterations++;
|
|
const { pickInRound: nextPickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
|
const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound);
|
|
if (!nextDraftSlot) return;
|
|
|
|
const nextTeamId = nextDraftSlot.teamId;
|
|
|
|
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
|
where: and(
|
|
eq(schema.autodraftSettings.seasonId, seasonId),
|
|
eq(schema.autodraftSettings.teamId, nextTeamId)
|
|
),
|
|
});
|
|
|
|
if (!autodraftSettings?.isEnabled) return;
|
|
|
|
logger.log(
|
|
`[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${currentPickNumber}`
|
|
);
|
|
|
|
const result = await executeAutoPick({
|
|
seasonId,
|
|
teamId: nextTeamId,
|
|
pickNumber: currentPickNumber,
|
|
triggeredBy: "timer",
|
|
autodraftSettings,
|
|
db,
|
|
chainEnabled: false,
|
|
});
|
|
|
|
if (!result.success || result.isDraftComplete || !result.nextPickNumber) return;
|
|
|
|
currentPickNumber = result.nextPickNumber;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
queueOnly?: boolean
|
|
) {
|
|
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
|
|
);
|
|
|
|
logger.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) {
|
|
logger.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.seasonParticipants.findMany({
|
|
where: inArray(schema.seasonParticipants.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) {
|
|
logger.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) {
|
|
logger.log(
|
|
`[AutoPick] Queue item ${participant.name} (${participant.sportsSeason.sport.name}) - already drafted, will remove`
|
|
);
|
|
ineligibleQueueItemIds.push(item.id);
|
|
continue;
|
|
}
|
|
|
|
if (!isEligible) {
|
|
logger.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
|
|
logger.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));
|
|
logger.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));
|
|
logger.log(
|
|
`[AutoPick] Removed ${ineligibleQueueItemIds.length} ineligible items from queue (all items were invalid)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Queue is empty or all queued players drafted/ineligible
|
|
if (queueOnly) {
|
|
logger.log(`[AutoPick] No valid queue items and queueOnly constraint is active — will not fall back to highest EV`);
|
|
return null;
|
|
}
|
|
|
|
// Pick highest EV available from eligible sports
|
|
logger.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;
|
|
}
|
|
|
|
// Handle multiple sports seasons: query each and sort in memory
|
|
if (sportsSeasonIds.length > 1) {
|
|
const allParticipants = [];
|
|
|
|
for (const sportsSeasonId of sportsSeasonIds) {
|
|
let participantQuery = db
|
|
.select()
|
|
.from(schema.seasonParticipants)
|
|
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
|
|
|
|
if (draftedIds.length > 0) {
|
|
participantQuery = db
|
|
.select()
|
|
.from(schema.seasonParticipants)
|
|
.where(
|
|
and(
|
|
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
|
|
notInArray(schema.seasonParticipants.id, draftedIds)
|
|
)
|
|
);
|
|
}
|
|
|
|
const seasonParticipants = await participantQuery;
|
|
allParticipants.push(...seasonParticipants);
|
|
}
|
|
|
|
// Sort by VORP desc, then name
|
|
allParticipants.sort((a, b) => {
|
|
const vorpA = parseFloat(String(a.vorpValue)) || 0;
|
|
const vorpB = parseFloat(String(b.vorpValue)) || 0;
|
|
if (vorpB !== vorpA) {
|
|
return vorpB - vorpA;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return allParticipants[0]?.id || null;
|
|
}
|
|
|
|
// Single sport season
|
|
let query = db
|
|
.select()
|
|
.from(schema.seasonParticipants)
|
|
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0]))
|
|
.orderBy(desc(schema.seasonParticipants.vorpValue), schema.seasonParticipants.name);
|
|
|
|
if (draftedIds.length > 0) {
|
|
query = db
|
|
.select()
|
|
.from(schema.seasonParticipants)
|
|
.where(
|
|
and(
|
|
eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonIds[0]),
|
|
notInArray(schema.seasonParticipants.id, draftedIds)
|
|
)
|
|
)
|
|
.orderBy(desc(schema.seasonParticipants.vorpValue), schema.seasonParticipants.name);
|
|
}
|
|
|
|
const [topParticipant] = await query;
|
|
return topParticipant?.id || null;
|
|
}
|
|
|
|
/**
|
|
* Calculate the current pick based on draft order and round (snake draft).
|
|
* Returns pickInRound that is already snake-adjusted and matches draftOrder values.
|
|
*/
|
|
export function calculatePickInfo(
|
|
pickNumber: number,
|
|
teamCount: number
|
|
): { round: number; pickInRound: number; teamIndex: number } {
|
|
const round = Math.ceil(pickNumber / teamCount);
|
|
const rawPickInRound = ((pickNumber - 1) % teamCount) + 1;
|
|
|
|
// Snake draft: odd rounds go forward, even rounds go backward
|
|
const isOddRound = round % 2 === 1;
|
|
const teamIndex = isOddRound ? rawPickInRound - 1 : teamCount - rawPickInRound;
|
|
const pickInRound = teamIndex + 1; // snake-adjusted, 1-based, matches draftOrder
|
|
|
|
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].toSorted((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;
|
|
}
|
|
|
|
/**
|
|
* 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<typeof database>;
|
|
}): 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<string, string>();
|
|
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) {
|
|
logger.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 });
|
|
logger.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
|
|
*
|
|
* 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
|
|
*/
|
|
type AutodraftSettings = InferSelectModel<typeof schema.autodraftSettings>;
|
|
|
|
export async function executeAutoPick(params: {
|
|
seasonId: string;
|
|
teamId: string;
|
|
pickNumber: number;
|
|
triggeredBy: "commissioner" | "timer";
|
|
commissionerUserId?: string;
|
|
autodraftSettings?: AutodraftSettings | null;
|
|
db?: ReturnType<typeof database>;
|
|
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
|
|
}): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
pick?: InferSelectModel<typeof schema.draftPicks>;
|
|
participant?: InferSelectModel<typeof schema.seasonParticipants> & {
|
|
sportsSeason: InferSelectModel<typeof schema.sportsSeasons> & {
|
|
sport: InferSelectModel<typeof schema.sports>;
|
|
};
|
|
};
|
|
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) {
|
|
logger.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: asc(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 queueOnly = autodraftSettings?.queueOnly ?? false;
|
|
const participantId = await autoPickForTeam(
|
|
seasonId,
|
|
teamId,
|
|
season.draftRounds,
|
|
allTeamIds,
|
|
db,
|
|
queueOnly
|
|
);
|
|
|
|
if (!participantId) {
|
|
// If queueOnly is set and queue is empty, disable autodraft and return success
|
|
// (timer will fire again and pick highest EV once autodraft is disabled)
|
|
if (triggeredBy === "timer" && queueOnly && autodraftSettings) {
|
|
logger.log(`[AutoPick] Queue empty with queueOnly constraint — disabling autodraft for team ${teamId}`);
|
|
await db
|
|
.update(schema.autodraftSettings)
|
|
.set({ isEnabled: false, updatedAt: new Date() })
|
|
.where(eq(schema.autodraftSettings.id, autodraftSettings.id));
|
|
|
|
try {
|
|
getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", {
|
|
teamId,
|
|
isEnabled: false,
|
|
mode: autodraftSettings.mode,
|
|
queueOnly: autodraftSettings.queueOnly,
|
|
});
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Socket.IO autodraft-updated error (queue-empty shutoff):", error);
|
|
}
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
error: "No eligible participants available to pick",
|
|
};
|
|
}
|
|
|
|
// Get participant details
|
|
const participantToPick = await db.query.seasonParticipants.findFirst({
|
|
where: eq(schema.seasonParticipants.id, participantId),
|
|
with: {
|
|
sportsSeason: {
|
|
with: {
|
|
sport: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!participantToPick) {
|
|
return {
|
|
success: false,
|
|
error: "Participant not found",
|
|
};
|
|
}
|
|
|
|
const { round: currentRound, pickInRound } = calculatePickInfo(pickNumber, totalTeams);
|
|
|
|
// Determine pickedByUserId based on trigger
|
|
const pickedByUserId = triggeredBy === "commissioner"
|
|
? (commissionerUserId || "")
|
|
: "";
|
|
|
|
// Fetch current timer before pick so we have the time remaining at decision point
|
|
const incrementTime = season.draftIncrementTime || 30;
|
|
const currentTimer = await db.query.draftTimers.findFirst({
|
|
where: and(
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
eq(schema.draftTimers.teamId, teamId)
|
|
),
|
|
});
|
|
|
|
if (!currentTimer) {
|
|
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`);
|
|
}
|
|
|
|
// Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer
|
|
// ticks racing to the same pick slot are handled atomically at the DB level
|
|
// rather than relying on the TOCTOU pre-check above.
|
|
const [draftPick] = await db
|
|
.insert(schema.draftPicks)
|
|
.values({
|
|
seasonId,
|
|
teamId,
|
|
participantId: participantToPick.id,
|
|
pickNumber,
|
|
round: currentRound,
|
|
pickInRound,
|
|
pickedByUserId,
|
|
pickedByType: "auto",
|
|
// Records the team's bank balance at the moment the pick was made (seconds remaining)
|
|
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning();
|
|
|
|
if (!draftPick) {
|
|
// Another concurrent path already committed this pick
|
|
logger.log(`[AutoPick] Pick ${pickNumber} already made (conflict on insert), skipping`);
|
|
return { success: false, error: "Pick already made" };
|
|
}
|
|
|
|
logger.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;
|
|
|
|
// Update the team's timer after the auto-pick.
|
|
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
|
// Chess clock mode: add the increment so the team starts their next turn with some time
|
|
// (without this, a single timeout would permanently freeze their bank at 0).
|
|
let emitTimeRemaining: number;
|
|
|
|
if (season.draftTimerMode === "standard") {
|
|
const [updatedTimer] = await db
|
|
.update(schema.draftTimers)
|
|
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
eq(schema.draftTimers.teamId, teamId)
|
|
)
|
|
)
|
|
.returning();
|
|
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
logger.log(
|
|
`[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
|
|
);
|
|
} else {
|
|
// Chess clock: add the increment (atomic add, same as a manual pick).
|
|
const [updatedTimer] = await db
|
|
.update(schema.draftTimers)
|
|
.set({
|
|
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(schema.draftTimers.seasonId, seasonId),
|
|
eq(schema.draftTimers.teamId, teamId)
|
|
)
|
|
)
|
|
.returning();
|
|
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
if (!updatedTimer) {
|
|
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining });
|
|
}
|
|
logger.log(
|
|
`[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)`
|
|
);
|
|
}
|
|
|
|
try {
|
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
|
seasonId,
|
|
teamId,
|
|
timeRemaining: emitTimeRemaining,
|
|
currentPickNumber: nextPickNumber,
|
|
overnightPauseActive: false,
|
|
});
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Socket.IO timer-update error:", error);
|
|
}
|
|
|
|
// Next team's timer is unchanged — their bank carries forward as-is
|
|
|
|
// Update season's current pick number
|
|
await db
|
|
.update(schema.seasons)
|
|
.set({
|
|
currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
|
status: isDraftComplete ? "active" : season.status,
|
|
draftCompletedAt: isDraftComplete ? new Date() : undefined,
|
|
})
|
|
.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)
|
|
)
|
|
);
|
|
|
|
// 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) {
|
|
logger.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") {
|
|
logger.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 {
|
|
getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", {
|
|
teamId,
|
|
isEnabled: false,
|
|
mode: autodraftSettings.mode,
|
|
queueOnly: autodraftSettings.queueOnly,
|
|
});
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Socket.IO autodraft-updated error:", error);
|
|
}
|
|
}
|
|
|
|
// Emit socket events
|
|
try {
|
|
const io = getSocketIO();
|
|
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");
|
|
scheduleDraftRoomClosure(seasonId);
|
|
}
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Socket.IO events error:", error);
|
|
}
|
|
|
|
// Recompute Brackt EV/VORP after this pick is committed so autopick paths
|
|
// (force-autopick, timer, user autodraft) are not one pick behind.
|
|
try {
|
|
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
|
if (updates.length > 0) {
|
|
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
|
}
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error);
|
|
}
|
|
|
|
// Check if next team has autodraft enabled and trigger immediately
|
|
// Only run the chain from the top-level call to prevent recursion
|
|
if (!isDraftComplete && params.chainEnabled !== false) {
|
|
await checkAndTriggerNextAutodraft({
|
|
seasonId,
|
|
nextPickNumber,
|
|
totalTeams,
|
|
draftSlots,
|
|
db,
|
|
});
|
|
|
|
// Fire-and-forget: sendOnTheClockEmail fetches the current pick number from
|
|
// the DB itself, so it always sees the post-chain state without blocking here.
|
|
sendOnTheClockEmail({ seasonId, totalTeams, draftSlots, db })
|
|
.catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err));
|
|
}
|
|
|
|
const pickedSlot = draftSlots.find((s) => s.teamId === teamId);
|
|
if (!pickedSlot) {
|
|
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
|
|
} else {
|
|
notifyPickMadeOnDiscord({
|
|
seasonId,
|
|
leagueId: season.leagueId,
|
|
pickedTeamName: pickedSlot.team.name,
|
|
participantName: participantToPick.name,
|
|
sportName: participantToPick.sportsSeason.sport.name,
|
|
pickNumber,
|
|
round: currentRound,
|
|
pickInRound,
|
|
isDraftComplete,
|
|
totalTeams,
|
|
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
|
|
db,
|
|
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err));
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
pick: draftPick,
|
|
participant: participantToPick,
|
|
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
|
|
isDraftComplete,
|
|
};
|
|
} catch (error) {
|
|
logger.error("[AutoPick] Error in executeAutoPick:", error);
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|