import * as schema from "~/database/schema"; import { eq, and, asc, sql, lte, isNotNull } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; import { getSocketIO } from "./socket"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause"; import { logger } from "./logger"; import { db } from "./db"; import { startDraft } from "~/services/draft-autostart"; let timerInterval: NodeJS.Timeout | null = null; let timerTickRunning = false; // Draft slots never change during an active draft — cache them to avoid // a redundant query on every tick. const draftSlotsCache = new Map(); // Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone). // Populated lazily; evicted when the season leaves active drafting. const teamTimezoneCache = new Map>(); async function getTeamTimezone(seasonId: string, teamId: string): Promise { let seasonMap = teamTimezoneCache.get(seasonId); if (!seasonMap) { try { const rows = await db .select({ teamId: schema.teams.id, timezone: schema.users.timezone }) .from(schema.teams) .leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`) .where(eq(schema.teams.seasonId, seasonId)); seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null])); } catch (err) { // ownerId column is varchar; if any row still holds a non-UUID value // (pre-migration remnant) the ::uuid cast throws. Degrade gracefully. logger.error("[Timer] getTeamTimezone cast failed, skipping overnight check:", err); seasonMap = new Map(); } teamTimezoneCache.set(seasonId, seasonMap); } return seasonMap.get(teamId) ?? null; } /** * Determine whether the current team's pick is in an overnight pause window. * Returns { active: false } or { active: true, resumesAtUTC: number }. */ async function checkOvernightPause( season: InferSelectModel, currentTeamId: string ): Promise<{ active: boolean; resumesAtUTC?: number }> { const mode = season.overnightPauseMode; const start = season.overnightPauseStart; const end = season.overnightPauseEnd; if (mode === "none" || !start || !end) return { active: false }; let tz: string | null = null; if (mode === "league") { tz = season.overnightPauseTimezone || null; } else { // per_user: use the team owner's timezone, fall back to league timezone tz = await getTeamTimezone(season.id, currentTeamId); if (!tz) tz = season.overnightPauseTimezone || null; } if (!tz) return { active: false }; if (isInOvernightWindow(tz, start, end)) { const resumesAt = getOvernightResumeUTC(tz, end); return { active: true, resumesAtUTC: resumesAt.getTime() }; } return { active: false }; } /** * Start the draft timer system * Runs every second to update all active draft timers */ export function startDraftTimerSystem(): void { if (timerInterval) { logger.log("[Timer] Timer system already running"); return; } timerInterval = setInterval(async () => { // Skip this tick if the previous one is still running (e.g. long autodraft chain) // to prevent concurrent ticks from racing on the same pick slot. if (timerTickRunning) return; timerTickRunning = true; try { await updateDraftTimers(); } catch (error) { logger.error("[Timer] Error updating draft timers:", error); } finally { timerTickRunning = false; } }, 1000); logger.log("[Timer] Draft timer system started"); } /** * Stop the draft timer system */ export function stopDraftTimerSystem(): void { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; logger.log("[Timer] Draft timer system stopped"); } } async function checkAndAutoStartDrafts(): Promise { const io = getSocketIO(); const seasonsToStart = await db.query.seasons.findMany({ where: and( eq(schema.seasons.status, "pre_draft"), eq(schema.seasons.autoStartDraft, true), isNotNull(schema.seasons.draftDateTime), lte(schema.seasons.draftDateTime, new Date()) ), }); for (const season of seasonsToStart) { logger.log(`[Timer] Auto-starting draft for season ${season.id}`); const result = await startDraft({ seasonId: season.id, actorUserId: "system", actorDisplayName: "System (auto-start)", db, io, }); if (result.success) continue; if (result.error === "Draft already started or completed") continue; logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`); if (result.error === "No draft slots found for this season") { // Disable auto-start to stop the retry loop. The commissioner will see // the setting is now off and can re-enable it once the order is set. await db .update(schema.seasons) .set({ autoStartDraft: false }) .where(eq(schema.seasons.id, season.id)); logger.log(`[Timer] Disabled autoStartDraft for season ${season.id} — no draft order set`); } } } /** * Update all active draft timers * Called every second by the timer interval */ async function updateDraftTimers(): Promise { await checkAndAutoStartDrafts(); const io = getSocketIO(); // Get all active drafts const activeDrafts = await db.query.seasons.findMany({ where: eq(schema.seasons.status, "draft"), }); if (activeDrafts.length === 0) { draftSlotsCache.clear(); return; } // Evict cache entries for seasons no longer actively drafting. const activeIds = new Set(activeDrafts.map((s) => s.id)); for (const cachedId of draftSlotsCache.keys()) { if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId); } for (const cachedId of teamTimezoneCache.keys()) { if (!activeIds.has(cachedId)) teamTimezoneCache.delete(cachedId); } for (const season of activeDrafts) { // Skip if draft is paused if (season.draftPaused) { continue; } const currentPickNumber = season.currentPickNumber ?? 1; // Draft slots never change during an active draft — use the cache. let draftSlots = draftSlotsCache.get(season.id); if (!draftSlots) { draftSlots = await db.query.draftSlots.findMany({ where: eq(schema.draftSlots.seasonId, season.id), orderBy: asc(schema.draftSlots.draftOrder), }); draftSlotsCache.set(season.id, draftSlots); } const totalTeams = draftSlots.length; if (totalTeams === 0) continue; const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams); const currentDraftSlot = draftSlots.find( (slot) => slot.draftOrder === pickInRound ); if (!currentDraftSlot) { continue; } const currentTeamId = currentDraftSlot.teamId; // Get current team's timer const timer = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, season.id), eq(schema.draftTimers.teamId, currentTeamId) ), }); if (!timer) { logger.warn( `[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time` ); // Standard mode seeds with the per-pick time; chess clock seeds with the full bank. const initialTime = season.draftTimerMode === "standard" ? (season.draftIncrementTime || 30) : (season.draftInitialTime || 120); await db .insert(schema.draftTimers) .values({ seasonId: season.id, teamId: currentTeamId, timeRemaining: initialTime, }); // Emit timer update so clients are aware of the new timer io.to(`draft-${season.id}`).emit("timer-update", { seasonId: season.id, teamId: currentTeamId, timeRemaining: initialTime, currentPickNumber, }); // Continue processing with the newly created timer on the next tick continue; } // Fetch autodraft settings once — used both for while_on bypass and timer-expiry path. const autodraftSettings = await db.query.autodraftSettings.findFirst({ where: and( eq(schema.autodraftSettings.seasonId, season.id), eq(schema.autodraftSettings.teamId, currentTeamId) ), }); const shouldAutodraft = autodraftSettings?.isEnabled ?? false; // while_on means "pick immediately when it's my turn" — bypass the countdown. const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; // Overnight pause: freeze timer for non-autodraft teams in their overnight window. const overnightPause = await checkOvernightPause(season, currentTeamId); const isOvernightFreeze = overnightPause.active && !shouldAutodraft; // Trigger pick when: timer expired OR team is in while_on autodraft mode. // The chain picks consecutive while_on teams after each pick, but it is capped at // totalTeams iterations. The while_on bypass here fills the gap: the timer picks // the next while_on team within one tick (≤1 s) rather than waiting for the full // countdown to expire. If the chain already made this pick, executeAutoPick // returns "Pick already made" which triggerAutoPick treats as a non-fatal no-op. if (timer.timeRemaining <= 0 || isWhileOn) { if (timer.timeRemaining <= 0) { logger.log( `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})` ); } else { logger.log( `[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})` ); // Emit 0 so clients see the timer hit zero before the pick-made event arrives. io.to(`draft-${season.id}`).emit("timer-update", { seasonId: season.id, teamId: currentTeamId, timeRemaining: 0, currentPickNumber, overnightPauseActive: false, }); } const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null); if (!success) { logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`); await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id)); io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true }); } continue; } // Overnight freeze: skip decrement, notify clients to display the pause indicator. if (isOvernightFreeze) { io.to(`draft-${season.id}`).emit("timer-update", { seasonId: season.id, teamId: currentTeamId, timeRemaining: timer.timeRemaining, currentPickNumber, overnightPauseActive: true, resumesAtUTC: overnightPause.resumesAtUTC, }); continue; } // Atomically decrement timer (race-condition safe: uses DB-level update // so concurrent increments from pick handlers are never overwritten) const [updatedTimer] = await db .update(schema.draftTimers) .set({ timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`, updatedAt: new Date(), }) .where(eq(schema.draftTimers.id, timer.id)) .returning(); const newTimeRemaining = updatedTimer?.timeRemaining ?? 0; // Emit timer update to all clients in the draft room io.to(`draft-${season.id}`).emit("timer-update", { seasonId: season.id, teamId: currentTeamId, timeRemaining: newTimeRemaining, currentPickNumber, overnightPauseActive: false, }); // If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft) if (newTimeRemaining === 0) { const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null); if (!success) { logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`); await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id)); io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true }); } } } } /** * Trigger an automatic pick when timer expires. * Returns true if the pick succeeded (or was already made by another path), * false if a real failure occurred that requires commissioner intervention. */ async function triggerAutoPick( seasonId: string, teamId: string, pickNumber: number, autodraftSettings: InferSelectModel | null ): Promise { try { const result = await executeAutoPick({ seasonId, teamId, pickNumber, triggeredBy: "timer", autodraftSettings, db, }); if (!result.success) { // A race condition where the pick was already made is not a real failure if (result.error === "Pick already made") { return true; } logger.error(`[Timer] Auto-pick failed: ${result.error}`); return false; } return true; } catch (error) { logger.error("[Timer] Error in triggerAutoPick:", error); return false; } }