brackt/app/routes/api/draft.make-pick.ts
Chris Parsons 789408e428
Fix chess clock increment not applied to all pick types (#67 regression) (#220)
Fixes #220

When standard draft mode was added, the chess clock increment was
accidentally restricted to owner-only picks. Commissioner, admin, and
auto-picks stopped earning the increment, causing teams that timed out
to freeze at 0s and instant-autopick every subsequent round.

Fix:
- make-pick: all pick types earn the increment in chess clock mode
- force-manual-pick: same; deduplicate standard/chess-clock branches
  into a single update with mode-selected SQL; remove now-unused
  timerSnapshot query
- draft-utils executeAutoPick: restore increment for chess clock
  auto-picks; add missing seed insert when no timer row exists
- server/timer.ts: fix fallback initialization to use draftIncrementTime
  in standard mode (was always using draftInitialTime)
- leagues/$leagueId.tsx: show Draft Timer Mode in League Info panel

Tests:
- Update draft.make-pick.timer-mode to cover owner/commissioner/admin
  in both modes (commissioner section previously asserted frozen bank)
- Add draft.force-manual-pick.timer-mode for commissioner/admin force
  picks in both modes
- Add executeAutoPick.timer for timer-triggered auto-picks in both modes
- Update draft.force-manual-pick to reflect new chess clock behavior
- Replace fragile toHaveBeenCalledTimes(2) assertions with
  toHaveBeenCalledWith checks on the timer set call

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:00:32 -07:00

320 lines
10 KiB
TypeScript

import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, sql } from "drizzle-orm";
import { isUserAdminByClerkId } from "~/models/user";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO } from "../../../server/socket";
import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) {
const { request } = args;
const { userId } = await getAuth(args);
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 } = 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([
isUserAdminByClerkId(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.participants.findFirst({
where: eq(schema.participants.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] || "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,
})
.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");
}
} catch (error) {
logger.error("Socket.IO error:", error);
}
// Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) {
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
if (!freshSeason?.draftPaused) {
await checkAndTriggerNextAutodraft({
seasonId,
nextPickNumber,
totalTeams,
draftSlots,
db,
});
}
}
return Response.json({
success: true,
pick: draftPick,
nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber,
isDraftComplete,
});
}