import { auth } from "~/lib/auth.server"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq, and, sql } from "drizzle-orm"; import { isUserAdmin } from "~/models/user"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick"; import { getParticipantsForSeasonWithSports } from "~/models/season-participant"; import { getSeasonSportsSimple } from "~/models/season-sport"; import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils"; import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket"; import { logger } from "~/lib/logger"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; if (!userId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const formData = await request.formData(); const seasonId = formData.get("seasonId") as string; const participantId = formData.get("participantId") as string; if (!seasonId || !participantId) { return Response.json({ error: "Missing required fields" }, { status: 400 }); } const db = database(); // Get season details const season = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }); if (!season) { return Response.json({ error: "Season not found" }, { status: 404 }); } if (season.status !== "draft") { return Response.json({ error: "Draft is not currently active" }, { status: 400 }); } if (season.draftPaused) { return Response.json({ error: "Draft is currently paused" }, { status: 400 }); } // Get current draft slot (who should be picking now) const currentPickNumber = season.currentPickNumber || 1; const draftSlots = await db.query.draftSlots.findMany({ where: eq(schema.draftSlots.seasonId, seasonId), orderBy: schema.draftSlots.draftOrder, with: { team: true, }, }); const totalTeams = draftSlots.length; const { round: currentRound, pickInRound, rawPickInRound } = calculatePickInfo(currentPickNumber, totalTeams); const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound); if (!currentDraftSlot) { return Response.json({ error: "Invalid draft state" }, { status: 500 }); } // Check permissions: must be team owner or commissioner/admin // Capture both results to set pickedByType accurately in the audit record const isTeamOwner = currentDraftSlot.team.ownerId === userId; const [isAdmin, commissionerRecord] = await Promise.all([ isUserAdmin(userId), db.query.commissioners.findFirst({ where: and( eq(schema.commissioners.leagueId, season.leagueId), eq(schema.commissioners.userId, userId) ), }), ]); if (!isTeamOwner && !isAdmin && !commissionerRecord) { return Response.json({ error: "You do not have permission to pick for this team" }, { status: 403 }); } // Check if participant is already drafted const existingPick = await db.query.draftPicks.findFirst({ where: and( eq(schema.draftPicks.seasonId, seasonId), eq(schema.draftPicks.participantId, participantId) ), }); if (existingPick) { return Response.json({ error: "Participant already drafted" }, { status: 400 }); } // Get participant details const participant = await db.query.seasonParticipants.findFirst({ where: eq(schema.seasonParticipants.id, participantId), with: { sportsSeason: { with: { sport: true, }, }, }, }); if (!participant) { return Response.json({ error: "Participant not found" }, { status: 404 }); } // ELIGIBILITY VALIDATION: Check if team can draft from this sport const allPicks = await getDraftPicksWithSports(seasonId); const teamPicks = await getTeamDraftPicksWithSports(currentDraftSlot.teamId, seasonId); const allParticipants = await getParticipantsForSeasonWithSports(seasonId); const seasonSports = await getSeasonSportsSimple(seasonId); // Get all teams for the season const allTeams = draftSlots.map((slot) => ({ id: slot.teamId })); const eligibility = calculateDraftEligibility( currentDraftSlot.teamId, teamPicks, allPicks, allParticipants, seasonSports, season.draftRounds, allTeams ); const sportId = participant.sportsSeason.sport.id; if (!eligibility.eligibleSportIds.has(sportId)) { const reason = eligibility.ineligibleReasons[sportId]?.message || "Cannot draft from this sport"; return Response.json({ error: reason }, { status: 400 }); } // Snapshot the team's time bank before the pick (used for audit / pick history) const timerSnapshot = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, currentDraftSlot.teamId) ), }); // Create the draft pick const [draftPick] = await db .insert(schema.draftPicks) .values({ seasonId, teamId: currentDraftSlot.teamId, participantId, pickNumber: currentPickNumber, round: currentRound, pickInRound, pickedByUserId: userId, pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin", timeUsed: timerSnapshot?.timeRemaining ?? 0, }) .returning(); // 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, participantId) ) ); // Notify all clients that this participant was removed from queues try { getSocketIO().to(`draft-${seasonId}`).emit("participant-removed-from-queues", { participantId, }); } catch (error) { logger.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) { logger.error("Queue pruning error after pick:", error); } // Calculate next pick info (before updating season) const nextPickNumber = currentPickNumber + 1; const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; // Update the picking team's timer after their pick. // Standard mode: always reset to the per-pick time, regardless of who picked. // Chess clock: always add the increment (any pick type — owner, commissioner, or admin). const incrementTime = season.draftIncrementTime || 30; let newTimeRemaining: number; let updatedTimer: { timeRemaining: number } | undefined; if (season.draftTimerMode === "standard") { // Atomic reset so the timer loop cannot race with this write. [updatedTimer] = await db .update(schema.draftTimers) .set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() }) .where( and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, currentDraftSlot.teamId) ) ) .returning(); newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; } else { // Chess clock: earn the increment regardless of who made the pick (atomic add). [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, currentDraftSlot.teamId) ) ) .returning(); newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; } // If the timer row didn't exist yet, seed it. if (!updatedTimer) { await db.insert(schema.draftTimers).values({ seasonId, teamId: currentDraftSlot.teamId, timeRemaining: newTimeRemaining, }); } try { getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { seasonId, teamId: currentDraftSlot.teamId, timeRemaining: newTimeRemaining, currentPickNumber: nextPickNumber, }); } catch (error) { logger.error("Socket.IO timer-update error:", error); } // Next team's timer is unchanged — their bank carries forward as-is // (no emit needed; the timer system will start decrementing their existing bank) // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db .update(schema.seasons) .set({ currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, status: isDraftComplete ? "active" : season.status, draftCompletedAt: isDraftComplete ? new Date() : undefined, }) .where(eq(schema.seasons.id, seasonId)); // Emit socket event try { getSocketIO().to(`draft-${seasonId}`).emit("pick-made", { pick: { ...draftPick, team: currentDraftSlot.team, participant: { ...participant, sport: participant.sportsSeason.sport, }, sport: participant.sportsSeason.sport, }, nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, isDraftComplete, }); if (isDraftComplete) { getSocketIO().to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); } } catch (error) { logger.error("Socket.IO error:", error); } // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { try { const updates = await runBracktHarvilleForFantasySeason(seasonId, db); if (updates.length > 0) { getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); } } catch (error) { logger.error("Brackt EV update after pick failed:", error); } const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); if (!freshSeason?.draftPaused) { 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("On-the-clock email failed:", err)); } notifyPickMadeOnDiscord({ seasonId, leagueId: season.leagueId, pickedTeamName: currentDraftSlot.team.name, participantName: participant.name, sportName: participant.sportsSeason.sport.name, pickNumber: currentPickNumber, nextPickNumber, round: currentRound, rawPickInRound, isDraftComplete, totalTeams, draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), db, }).catch((err) => logger.error("Discord pick announcement failed:", err)); return Response.json({ success: true, pick: draftPick, nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, isDraftComplete, }); }