diff --git a/app/components/AutodraftSettings.tsx b/app/components/AutodraftSettings.tsx index 88ae76e..594edb7 100644 --- a/app/components/AutodraftSettings.tsx +++ b/app/components/AutodraftSettings.tsx @@ -82,6 +82,14 @@ function toAutodraftState( return queueOnly ? "all_queue" : "all_picks"; } +export function getAutodraftLabel( + isEnabled: boolean, + mode: AutodraftMode, + queueOnly: boolean +): string { + return OPTIONS[toAutodraftState(isEnabled, mode, queueOnly)].label; +} + interface AutodraftSettingsProps { seasonId: string; teamId: string; diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index f87ac53..e2d6806 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -47,10 +47,11 @@ interface DraftGridSectionProps { >; currentPick: number; teamTimers: Record; - autodraftStatus: Record; + autodraftStatus: Record; connectedTeams: Set; isCommissioner: boolean; onAdjustTimeBankOpen?: (teamId: string) => void; + onSetAutodraftOpen?: (teamId: string) => void; onForceAutopick: (pickNumber: number, teamId: string) => void; onForceManualPickOpen: (pickNumber: number, teamId: string) => void; onReplacePick?: (pickNumber: number, teamId: string) => void; @@ -67,6 +68,7 @@ export const DraftGridSection = memo(function DraftGridSection({ connectedTeams, isCommissioner, onAdjustTimeBankOpen, + onSetAutodraftOpen, onForceAutopick, onForceManualPickOpen, onReplacePick, @@ -91,7 +93,7 @@ export const DraftGridSection = memo(function DraftGridSection({
{draftSlots.map((slot) => { const teamTime = teamTimers[slot.team.id]; - const isAutodraft = autodraftStatus[slot.team.id] || false; + const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false; const isConnected = connectedTeams.has(slot.team.id); const headerInner = ( @@ -117,7 +119,7 @@ export const DraftGridSection = memo(function DraftGridSection({ )}
- {isCommissioner && onAdjustTimeBankOpen && ( + {isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && ( + {mobileSheet?.type === "team" && ( + <> + {onAdjustTimeBankOpen && ( + + )} + {onSetAutodraftOpen && ( + + )} + )} {mobileSheet?.type === "current-cell" && ( <> diff --git a/app/routes/api/autodraft.update.ts b/app/routes/api/autodraft.update.ts index 6bce918..a3c4902 100644 --- a/app/routes/api/autodraft.update.ts +++ b/app/routes/api/autodraft.update.ts @@ -1,8 +1,10 @@ import { getAuth } from "@clerk/react-router/server"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and } from "drizzle-orm"; +import { eq, and, asc } from "drizzle-orm"; import { getSocketIO } from "~/server/socket"; +import { isUserAdminByClerkId } from "~/models/user"; +import { getTeamForPick } from "~/lib/draft-order"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -26,15 +28,38 @@ export async function action(args: ActionFunctionArgs) { const db = database(); - // Verify user owns this team - const team = await db.query.teams.findFirst({ - where: eq(schema.teams.id, teamId), - }); + // Fetch team and season in parallel + const [team, season] = await Promise.all([ + db.query.teams.findFirst({ where: eq(schema.teams.id, teamId) }), + db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }), + ]); - if (!team || team.ownerId !== userId) { - return Response.json({ error: "Unauthorized" }, { status: 403 }); + if (!team || !season) { + return Response.json({ error: "Team or season not found" }, { status: 404 }); } + const isOwner = team.ownerId === userId; + + let isActingAsCommissioner = false; + if (!isOwner) { + const [commissioner, userIsAdmin] = await Promise.all([ + db.query.commissioners.findFirst({ + where: and( + eq(schema.commissioners.leagueId, season.leagueId), + eq(schema.commissioners.userId, userId) + ), + }), + isUserAdminByClerkId(userId), + ]); + + if (!commissioner && !userIsAdmin) { + return Response.json({ error: "Unauthorized" }, { status: 403 }); + } + isActingAsCommissioner = true; + } + + const resolvedSource: "commissioner" | "user" = isActingAsCommissioner ? "commissioner" : "user"; + // Check if autodraft settings exist const existingSettings = await db.query.autodraftSettings.findFirst({ where: and( @@ -77,7 +102,38 @@ export async function action(args: ActionFunctionArgs) { isEnabled, mode, queueOnly, + source: resolvedSource, }); + // If commissioner enables autodraft for the team currently on the clock, fire immediately + if (isEnabled && isActingAsCommissioner) { + import("~/models/draft-utils").then(async ({ executeAutoPick }) => { + const freshSeason = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + if (!freshSeason || freshSeason.status !== "draft") return; + + const currentPickNumber = freshSeason.currentPickNumber ?? 1; + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: asc(schema.draftSlots.draftOrder), + }); + + const currentSlot = getTeamForPick(currentPickNumber, draftSlots); + if (!currentSlot || currentSlot.teamId !== teamId) return; + + // Team is on the clock — trigger autopick + await executeAutoPick({ + seasonId, + teamId, + pickNumber: currentPickNumber, + triggeredBy: "commissioner", + db, + }); + }).catch((err) => { + console.error("[AutodraftUpdate] Mid-turn autopick failed:", err); + }); + } + return Response.json({ success: true, settings }); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 07d807d..46003f3 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -28,6 +28,7 @@ import { getTeamForPick } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { NotificationSettings } from "~/components/NotificationSettings"; +import { AutodraftSettings, getAutodraftLabel } from "~/components/AutodraftSettings"; import { toast } from "sonner"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; @@ -35,6 +36,12 @@ import type { Route } from "./+types/$leagueId.draft.$seasonId"; type QueueItem = typeof schema.draftQueue.$inferSelect; +type AutodraftStatusEntry = { + isEnabled: boolean; + mode: "next_pick" | "while_on"; + queueOnly: boolean; +}; + const MOBILE_TABS_BASE = [ { id: "available" as const, label: "Available", Icon: Users }, { id: "board" as const, label: "Board", Icon: LayoutGrid }, @@ -492,9 +499,13 @@ export default function DraftRoom() { // Sync autodraft settings — teams may have toggled autodraft while // we were away. setAutodraftStatus(() => { - const status: Record = {}; + const status: Record = {}; autodraftSettings.forEach((setting: any) => { - status[setting.teamId] = setting.isEnabled; + status[setting.teamId] = { + isEnabled: setting.isEnabled, + mode: setting.mode, + queueOnly: setting.queueOnly, + }; }); return status; }); @@ -528,10 +539,14 @@ export default function DraftRoom() { const isMobile = useMediaQuery("(max-width: 767px)"); // Track autodraft status for all teams - const [autodraftStatus, setAutodraftStatus] = useState>(() => { - const status: Record = {}; + const [autodraftStatus, setAutodraftStatus] = useState>(() => { + const status: Record = {}; autodraftSettings.forEach((setting: any) => { - status[setting.teamId] = setting.isEnabled; + status[setting.teamId] = { + isEnabled: setting.isEnabled, + mode: setting.mode, + queueOnly: setting.queueOnly, + }; }); return status; }); @@ -579,6 +594,8 @@ export default function DraftRoom() { // Time bank adjustment dialog state const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); const [timeBankTeamId, setTimeBankTeamId] = useState(null); + const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false); + const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState(null); const [timeBankAmount, setTimeBankAmount] = useState("1"); const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes"); const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add"); @@ -743,18 +760,32 @@ export default function DraftRoom() { setIsDraftComplete(true); }; - const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => { + const handleAutodraftUpdated = (data: { + teamId: string; + isEnabled: boolean; + mode: "next_pick" | "while_on"; + queueOnly: boolean; + source?: "commissioner" | "user"; + }) => { setAutodraftStatus((prev) => ({ ...prev, - [data.teamId]: data.isEnabled, + [data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly }, })); // Update user's local state if it's their team if (userTeam && data.teamId === userTeam.id) { - // Detect server-side auto-disable: was enabled with queueOnly, now disabled - const prev = userAutodraftRef.current; - if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") { - toast.info("Autodraft disabled — your queue is empty"); + if (data.source === "commissioner") { + if (!data.isEnabled) { + toast.info("Commissioner turned off your autodraft"); + } else { + toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`); + } + } else { + // Detect server-side auto-disable: was enabled with queueOnly, now disabled + const prev = userAutodraftRef.current; + if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") { + toast.info("Autodraft disabled — your queue is empty"); + } } setUserAutodraft({ @@ -1235,6 +1266,16 @@ export default function DraftRoom() { setTimeBankDialogOpen(true); }, []); + const handleSetAutodraftOpen = useCallback((teamId: string) => { + setCommissionerAutodraftTeamId(teamId); + setCommissionerAutodraftDialogOpen(true); + }, []); + + const handleCommissionerAutodraftUpdate = useCallback(() => { + setCommissionerAutodraftDialogOpen(false); + setCommissionerAutodraftTeamId(null); + }, []); + const handleConfirmAdjustTimeBank = async () => { if (!timeBankTeamId || isAdjustingTimeBank) return; @@ -1452,6 +1493,7 @@ export default function DraftRoom() { isCommissioner, ownerMap, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, + onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onForceAutopick: handleForceAutopick, onForceManualPickOpen: handleForceManualPickOpen, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, @@ -1990,6 +2032,37 @@ export default function DraftRoom() { + {/* Commissioner Autodraft Override Dialog */} + { + setCommissionerAutodraftDialogOpen(open); + if (!open) setCommissionerAutodraftTeamId(null); + }} + > + + + Set Autodraft + + {commissionerAutodraftTeamId + ? `Change autodraft for ${draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name ?? "this team"}` + : "Set a team's autodraft settings"} + + + {commissionerAutodraftTeamId && ( + + )} + + + {/* Connection Overlay - blocks interaction until socket connects */} ({ vi.mock("@clerk/react-router/server", () => ({ getAuth: vi.fn(), })); +vi.mock("~/models/user", () => ({ + isUserAdminByClerkId: vi.fn().mockResolvedValue(false), +})); describe("Autodraft Settings API", () => { let mockDb: any; @@ -39,6 +42,12 @@ describe("Autodraft Settings API", () => { teams: { findFirst: vi.fn(), }, + seasons: { + findFirst: vi.fn().mockResolvedValue({ id: "season-123", leagueId: "league-1" }), + }, + commissioners: { + findFirst: vi.fn().mockResolvedValue(null), + }, autodraftSettings: { findFirst: vi.fn(), }, @@ -115,6 +124,7 @@ describe("Autodraft Settings API", () => { isEnabled: true, mode: "next_pick", queueOnly: false, + source: "user", }); }); @@ -179,6 +189,7 @@ describe("Autodraft Settings API", () => { isEnabled: true, mode: "while_on", queueOnly: false, + source: "user", }); }); @@ -233,6 +244,7 @@ describe("Autodraft Settings API", () => { isEnabled: true, mode: "next_pick", queueOnly: true, + source: "user", }); }); @@ -418,6 +430,7 @@ describe("Autodraft Settings API", () => { isEnabled: true, mode: "next_pick", queueOnly: true, + source: "user", }); }); }); diff --git a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts index 9c63b78..d0d5f7b 100644 --- a/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts +++ b/app/routes/leagues/__tests__/draft-reconnection-sync.test.ts @@ -708,26 +708,31 @@ describe("full mobile reconnection scenario", () => { describe("autodraft and timer sync on revalidation completion", () => { it("syncs autodraftStatus from loader data on revalidation", () => { - let autodraftStatus: Record = { - "team-1": false, - "team-2": false, + type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }; + let autodraftStatus: Record = { + "team-1": { isEnabled: false, mode: "next_pick", queueOnly: false }, + "team-2": { isEnabled: false, mode: "next_pick", queueOnly: false }, }; // Simulate what happens on revalidation completion const autodraftSettings = [ - { teamId: "team-1", isEnabled: true }, - { teamId: "team-2", isEnabled: false }, - { teamId: "team-3", isEnabled: true }, + { teamId: "team-1", isEnabled: true, mode: "while_on" as const, queueOnly: false }, + { teamId: "team-2", isEnabled: false, mode: "next_pick" as const, queueOnly: false }, + { teamId: "team-3", isEnabled: true, mode: "next_pick" as const, queueOnly: true }, ]; autodraftStatus = {}; autodraftSettings.forEach((setting) => { - autodraftStatus[setting.teamId] = setting.isEnabled; + autodraftStatus[setting.teamId] = { + isEnabled: setting.isEnabled, + mode: setting.mode, + queueOnly: setting.queueOnly, + }; }); - expect(autodraftStatus["team-1"]).toBe(true); - expect(autodraftStatus["team-2"]).toBe(false); - expect(autodraftStatus["team-3"]).toBe(true); + expect(autodraftStatus["team-1"].isEnabled).toBe(true); + expect(autodraftStatus["team-2"].isEnabled).toBe(false); + expect(autodraftStatus["team-3"].isEnabled).toBe(true); }); it("syncs teamTimers from loader data on revalidation", () => { diff --git a/server/socket.ts b/server/socket.ts index 622ae75..7701ba8 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -39,6 +39,7 @@ interface ServerToClientEvents { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean; + source?: "commissioner" | "user"; }) => void; "team-connected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void;