brackt/app/routes/api/draft.force-manual-pick.ts
Chris Parsons 46f8552f60
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m39s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause
- Broadcast timer-bank-updated after every pick so all connected clients
  immediately see the updated time bank (was only visible on next timer-pick-started)
- Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil
  so credited seconds always match the client countdown display
- Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh
- Hold schedulingInProgress lock for full timer callback to prevent the recovery
  interval from scheduling a duplicate timeout mid-pick
- Fix force-autopick route: call rescheduleTimer so the next team's clock
  starts immediately instead of waiting for the old timeout to fire naturally
- Fix draft.adjust-time-bank for on-clock teams: shift picksExpiresAt by the
  adjustment and reschedule, so the client countdown updates; block adjustments
  that would reduce the bank to zero
- Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket
  events with full type definitions; replace dead timer-update event
- Fix draft-state-sync to include expiresAt for the active timer and
  isOvernightPause state so reconnecting clients see accurate countdown and
  pause banner immediately
- Fix room-closure countdown: capture client-side timestamp when draft completes
  so countdown runs even before the loader revalidates with draftCompletedAt
- Run countdown interval at 500ms with Math.ceil to prevent skipped seconds
- Add draft-started socket handler to transition pre-draft UI without a refresh
- Fix overnight pause: canPick only blocks on commissioner pause, not overnight
  pause (timer freezes but player can still pick early)
- Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the
  timer↔socket circular import and ensuring the timezone cache is shared and
  evicted correctly across both callers
- Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:42:19 -07:00

331 lines
11 KiB
TypeScript

import { auth } from "~/lib/auth.server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } 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 } from "~/models/draft-utils";
import { getTeamForPick } from "~/lib/draft-order";
import { enqueuePickNotification } from "~/services/discord";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
import { rescheduleTimer } from "../../../server/timer";
import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
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 teamId = formData.get("teamId") as string;
const participantId = formData.get("participantId") as string;
const pickNumber = parseInt(formData.get("pickNumber") as string);
if (!seasonId || !teamId || !participantId || !pickNumber) {
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 });
}
// Check if user is commissioner or site admin; capture both to set pickedByType accurately
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 (!isAdmin && !commissionerRecord) {
return Response.json({ error: "Only commissioners can force a manual pick" }, { status: 403 });
}
if (season.status !== "draft") {
return Response.json({ error: "Draft is not currently active" }, { status: 400 });
}
// Check if a pick already exists at this pick number slot (prevents duplicate picks at same slot)
const existingPickAtSlot = await db.query.draftPicks.findFirst({
where: and(
eq(schema.draftPicks.seasonId, seasonId),
eq(schema.draftPicks.pickNumber, pickNumber)
),
});
if (existingPickAtSlot) {
return Response.json({ error: "A pick already exists at this slot" }, { status: 400 });
}
// 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 });
}
// Calculate round and pickInRound
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(pickNumber, totalTeams);
// Validate that the submitted teamId is actually the team whose turn it is at pickNumber
const expectedDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound);
if (!expectedDraftSlot) {
return Response.json({ error: "Invalid draft state" }, { status: 500 });
}
if (expectedDraftSlot.teamId !== teamId) {
return Response.json({ error: "It is not this team's turn to pick at this slot" }, { status: 400 });
}
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
const allPicks = await getDraftPicksWithSports(seasonId);
const teamPicks = await getTeamDraftPicksWithSports(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(
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 });
}
// Create the draft pick
const [draftPick] = await db
.insert(schema.draftPicks)
.values({
seasonId,
teamId,
participantId,
pickNumber,
round: currentRound,
pickInRound,
pickedByUserId: userId,
pickedByType: commissionerRecord ? "commissioner" : "admin",
})
.returning();
// Calculate next pick info (before updating season)
const nextPickNumber = pickNumber + 1;
const totalPicks = totalTeams * season.draftRounds;
const isDraftComplete = nextPickNumber > totalPicks;
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
// Chess clock: add the increment to the actual remaining time (from picksExpiresAt).
// Also clear picksExpiresAt so the timer system knows this team's turn is over.
const incrementTime = season.draftIncrementTime || 30;
const timerSnapshot = await db.query.draftTimers.findFirst({
where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)),
});
const timeRemainingAtPick = (() => {
if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0;
const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt;
return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0;
})();
const newTimeRemaining =
season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime;
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({ timeRemaining: newTimeRemaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
.returning();
if (!updatedTimer) {
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
}
// rescheduleTimer (called below) will emit timer-pick-started for the next team.
// 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, 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);
}
// Emit socket event
try {
const io = getSocketIO();
const team = expectedDraftSlot.team;
io.to(`draft-${seasonId}`).emit("pick-made", {
pick: {
...draftPick,
team,
participant: {
...participant,
sport: participant.sportsSeason.sport,
},
sport: participant.sportsSeason.sport,
},
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
isDraftComplete,
});
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown first.
io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining });
if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId);
}
} catch (error) {
logger.error("Socket.IO error:", error);
}
// Announce pick on Discord
const nextSlot = !isDraftComplete ? getTeamForPick(nextPickNumber, draftSlots) : undefined;
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({
seasonId,
leagueId: season.leagueId,
pickedTeamName: expectedDraftSlot.team.name,
participantName: participant.name,
sportName: participant.sportsSeason.sport.name,
pickNumber,
round: currentRound,
pickInRound: rawPickInRound,
isDraftComplete,
nextTeamName: nextSlot?.team.name,
nextTeamOwnerId: nextSlot?.team.ownerId,
db,
}).catch((err) => logger.error("Discord pick announcement failed:", err))
);
await logCommissionerAction({
seasonId,
leagueId: season.leagueId,
actorUserId: userId,
action: "force_manual_pick",
affectedTeamIds: [teamId],
details: {
pickNumber,
teamId,
teamName: expectedDraftSlot.team.name,
participantId,
participantName: participant.name,
},
});
// Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) {
runBracktHarvilleForFantasySeason(seasonId, db)
.then((updates) => {
if (updates.length > 0) {
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
}
})
.catch((error) => logger.error("Brackt EV update after forced manual 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,
});
}
}
// Reschedule the timer for the next team on the clock (runs after the full autodraft chain).
try {
await rescheduleTimer(seasonId);
} catch (err) {
logger.error("[ForceManualPick] rescheduleTimer failed:", err);
}
return Response.json({
success: true,
pick: draftPick,
nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber,
isDraftComplete,
});
}