brackt/app/services/draft-autostart.ts
Chris Parsons d468385d90
Add auto-start draft at scheduled time (#437) (#438)
Commissioners can now opt in to having a draft start automatically at
its scheduled draftDateTime rather than requiring a manual "Start Draft"
click. The 1-second timer loop checks for eligible seasons each tick and
calls the new shared startDraft() service, which is also used by the
existing commissioner HTTP route.

- Add autoStartDraft boolean to seasons table (migration 0108)
- Extract core start logic into app/services/draft-autostart.ts so both
  the API route and the timer can call it without AsyncLocalStorage
- Add checkAndAutoStartDrafts() to the timer tick; disables autoStartDraft
  and stops retrying if no draft order is set at fire time
- Guard against null draftDateTime in both the timer query (isNotNull)
  and server actions (autoStartDraft forced false when datetime is null)
- Add "Auto-start at scheduled time" checkbox to league creation wizard
  (step 3) and league settings, gated on date+time being set
- Show countdown + "Start Now" button in draft room when auto-start is
  scheduled; reuses the existing nowForPauseCheck 1-second ticker
- Show orange warning banner on league homepage to commissioners when
  auto-start is within 1 hour and no draft order has been configured

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:37:29 -07:00

99 lines
2.8 KiB
TypeScript

import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { logger } from "~/lib/logger";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import type { Server as SocketIOServer } from "socket.io";
type Db = PostgresJsDatabase<typeof schema>;
export interface StartDraftOptions {
seasonId: string;
actorUserId: string;
actorDisplayName: string;
db: Db;
io: SocketIOServer;
}
export interface StartDraftResult {
success: boolean;
error?: string;
}
/**
* Core draft-start logic shared by the commissioner HTTP route and the
* auto-start timer. Accepts `db` directly (not via AsyncLocalStorage) so it
* works both inside React Router request context and in the server timer loop.
*/
export async function startDraft(options: StartDraftOptions): Promise<StartDraftResult> {
const { seasonId, actorUserId, actorDisplayName, db, io } = options;
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
});
if (!season) {
return { success: false, error: "Season not found" };
}
// Idempotency guard — status may have changed between the outer check and here
if (season.status !== "pre_draft") {
return { success: false, error: "Draft already started or completed" };
}
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
});
if (draftSlots.length === 0) {
return { success: false, error: "No draft slots found for this season" };
}
await db
.update(schema.seasons)
.set({
status: "draft",
currentPickNumber: 1,
draftStartedAt: new Date(),
})
.where(eq(schema.seasons.id, seasonId));
// Standard mode: each pick gets exactly the increment time.
// Chess clock mode: each team starts with the full initial bank.
const initialTime =
season.draftTimerMode === "standard"
? season.draftIncrementTime || 30
: season.draftInitialTime || 120;
await db.delete(schema.draftTimers).where(eq(schema.draftTimers.seasonId, seasonId));
await db.insert(schema.draftTimers).values(
draftSlots.map((slot) => ({
seasonId,
teamId: slot.teamId,
timeRemaining: initialTime,
}))
);
await db.insert(schema.commissionerAuditLog).values({
seasonId,
leagueId: season.leagueId,
actorUserId,
actorDisplayName,
action: "draft_started",
affectedTeamIds: [],
details: { pickNumber: 1, autoStarted: actorUserId === "system" },
});
try {
io.to(`draft-${seasonId}`).emit("draft-started", {
seasonId,
currentPickNumber: 1,
});
} catch (err) {
// Socket emit failure is non-fatal; DB state is already committed
logger.error("[startDraft] Socket emit failed:", err);
}
return { success: true };
}