All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m30s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m16s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
- Move notifyPickMadeOnDiscord() before checkAndTriggerNextAutodraft() in both executeAutoPick() and the manual pick action. Previously the triggering pick's announcement fired after all chained autodraft picks had already announced, causing Discord to show e.g. pick #106 before #105 before #104. Now each pick announces itself before the next pick in the chain is triggered. - Add a single retry with Retry-After delay in sendDiscordWebhook() on HTTP 429. A burst of consecutive autodraft picks could hit Discord's per-webhook rate limit and silently drop announcements. Use || 1 (not ?? 1) to handle non-numeric/NaN headers, and cap at 10 s so that awaited callers such as the draft-order settings actions are never hung for a full global rate-limit window. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
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);
|
|
}
|
|
|
|
// Announce before triggering the chain so Discord messages arrive in pick-number
|
|
// order. If the chain ran first, chained picks would announce before this one.
|
|
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));
|
|
|
|
// 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));
|
|
}
|
|
|
|
return Response.json({
|
|
success: true,
|
|
pick: draftPick,
|
|
nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
|
|
isDraftComplete,
|
|
});
|
|
}
|