brackt/server/socket.ts
chrisp f96c8f5244
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m40s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m26s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Fix draft timer: broadcasts, increments, reconnect sync, overnight pause (#72)
## Summary

- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: 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
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join

## Test plan

- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
2026-06-06 05:57:46 +00:00

441 lines
16 KiB
TypeScript

import type { Socket } from "socket.io";
import { Server as SocketIOServer } from "socket.io";
import type { Server as HTTPServer } from "http";
import * as schema from "~/database/schema";
import { eq, and, asc } from "drizzle-orm";
import { checkOvernightPause } from "./overnight-pause-check";
import { calculatePickInfo } from "~/models/draft-utils";
import { logger } from "./logger";
import { db } from "./db";
// Socket event types
interface ServerToClientEvents {
"test-message": (data: {
originalMessage: unknown;
serverResponse: string;
serverTimestamp: string;
socketId: string;
}) => void;
"pick-made": (data: unknown) => void;
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void;
"draft-room-closed": () => void;
"timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void;
"timer-pick-started": (data: {
seasonId: string;
teamId: string;
pickNumber: number;
expiresAt: number; // ms timestamp
timeRemaining: number; // seconds remaining at emit time
}) => void;
"timer-overnight-paused": (data: {
seasonId: string;
teamId: string;
resumesAtUTC?: number;
}) => void;
"autodraft-updated": (data: {
teamId: string;
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
source?: "commissioner" | "user";
reason?: "queue_empty" | "pick_complete";
}) => void;
"team-connected": (data: { teamId: string }) => void;
"team-disconnected": (data: { teamId: string }) => void;
"connected-teams-list": (data: { teamIds: string[] }) => void;
"draft-paused": (data: { seasonId: string; paused: boolean }) => void;
"draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
"participant-removed-from-queues": (data: { participantId: string }) => void;
"queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void;
"watchlist-updated": (data: { participantIds: string[] }) => void;
"brackt-evs-updated": (data: {
updates: Array<{ participantId: string; expectedValue: number; vorpValue: number }>;
}) => void;
"draft-state-sync": (data: {
currentPickNumber: number;
isPaused: boolean;
status: string;
isOvernightPause: boolean;
overnightResumesAt?: number;
picks: Array<{
id: string;
pickNumber: number;
round: number;
pickInRound: number;
timeUsed: number;
team: unknown;
participant: unknown;
sport: unknown;
}>;
timers: Array<{
teamId: string;
timeRemaining: number;
expiresAt?: number; // ms timestamp; present for the currently-active team
}>;
queue?: Array<{
id: string;
teamId: string;
seasonId: string;
participantId: string;
queuePosition: number;
}>;
watchlistParticipantIds?: string[];
}) => void;
}
interface ClientToServerEvents {
"join-draft": (seasonId: string, teamId?: string) => void;
"leave-draft": (seasonId: string) => void;
"test-event": (data: unknown) => void;
}
// Global type augmentation
declare global {
var __socketIO: SocketIOServer | undefined;
}
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | null = null;
function scheduleDraftRoomClosure(seasonId: string) {
if (draftRoomClosureTimers.has(seasonId)) return;
const timeout = setTimeout(() => {
logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`);
io?.to(`draft-${seasonId}`).emit("draft-room-closed");
draftRoomClosureTimers.delete(seasonId);
}, ROOM_CLOSURE_DELAY_MS);
draftRoomClosureTimers.set(seasonId, timeout);
}
export { scheduleDraftRoomClosure };
// Track connected teams per season (in-memory, single-instance only).
// If the server restarts or runs as multiple instances this map resets.
// For multi-instance deployments, replace with a Redis-backed Socket.IO adapter.
const connectedTeams = new Map<string, Set<string>>(); // seasonId -> Set<teamId>
const ROOM_CLOSURE_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const draftRoomClosureTimers = new Map<string, NodeJS.Timeout>(); // seasonId -> timeout
/**
* Initialize Socket.IO server
*/
export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
if (io) {
logger.log("Socket.IO already initialized");
return io;
}
// Create typed Socket.IO server
io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
// Faster dead-connection detection than the defaults (20s/25s).
// pingTimeout: how long to wait for a pong before declaring the connection dead.
// pingInterval: how often to send a ping.
// Together these detect a crashed server in ~15s instead of ~45s.
// Trade-off: more heartbeat traffic at scale; revisit if server load becomes a concern.
pingTimeout: 5000,
pingInterval: 10000,
cors: process.env.NODE_ENV === "production" && process.env.APP_URL
? {
origin: process.env.APP_URL,
credentials: true,
}
: undefined,
});
// Connection handling
io.on("connection", (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
logger.log("Client connected:", socket.id);
// Store team ID for this socket
let currentTeamId: string | undefined;
let currentSeasonId: string | undefined;
socket.on("join-draft", async (seasonId: string, teamId?: string) => {
if (!seasonId) {
logger.error("No seasonId provided for join-draft");
return;
}
try {
const seasonData = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
columns: { id: true, status: true, draftCompletedAt: true },
});
if (seasonData?.draftCompletedAt) {
const elapsed = Date.now() - seasonData.draftCompletedAt.getTime();
if (elapsed >= ROOM_CLOSURE_DELAY_MS) {
socket.emit("draft-room-closed");
logger.log(`[Socket] join-draft rejected for ${seasonId}: room closed (completed ${Math.round(elapsed / 60000)}m ago)`);
return;
}
if (!draftRoomClosureTimers.has(seasonId)) {
const remaining = ROOM_CLOSURE_DELAY_MS - elapsed;
const timeout = setTimeout(() => {
logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`);
io?.to(`draft-${seasonId}`).emit("draft-room-closed");
draftRoomClosureTimers.delete(seasonId);
}, remaining);
draftRoomClosureTimers.set(seasonId, timeout);
}
}
} catch (err) {
logger.error("[Socket] join-draft room-closure check failed:", err);
}
socket.join(`draft-${seasonId}`);
currentSeasonId = seasonId;
logger.log(`Socket ${socket.id} joined draft-${seasonId}`);
// If teamId provided, validate it belongs to this season before tracking
if (teamId) {
try {
const team = await db.query.teams.findFirst({
where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)),
});
if (!team) {
logger.warn(`[Socket] join-draft rejected: team ${teamId} does not belong to season ${seasonId}`);
return;
}
} catch (err) {
logger.error("[Socket] join-draft team validation failed:", err);
return;
}
currentTeamId = teamId;
socket.join(`team-${teamId}`);
// Initialize the season's connected teams set if it doesn't exist
if (!connectedTeams.has(seasonId)) {
connectedTeams.set(seasonId, new Set());
}
// Get current connected teams for this season
const seasonConnectedTeams = connectedTeams.get(seasonId) ?? new Set<string>();
if (!connectedTeams.has(seasonId)) connectedTeams.set(seasonId, seasonConnectedTeams);
// Send the list of already-connected teams to this socket
socket.emit("connected-teams-list", { teamIds: Array.from(seasonConnectedTeams) });
// Add this team to the connected teams set
seasonConnectedTeams.add(teamId);
// Broadcast to everyone else that this team connected
io?.to(`draft-${seasonId}`).emit("team-connected", { teamId });
logger.log(`Team ${teamId} connected to draft-${seasonId}. Total connected: ${seasonConnectedTeams.size}`);
}
// Send full draft state to the joining client so it can sync immediately.
// This is critical for mobile reconnection: the client may have been in the
// background for minutes/hours and missed many picks. The revalidate() path
// (HTTP loader re-fetch) covers this too, but it can fail due to expired auth
// tokens or flaky mobile networks. This socket-based sync provides an
// additional, more reliable path since the socket is already connected.
try {
const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([
db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
}),
db
.select({
id: schema.draftPicks.id,
pickNumber: schema.draftPicks.pickNumber,
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.seasonParticipants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber)),
db.query.draftTimers.findMany({
where: eq(schema.draftTimers.seasonId, seasonId),
}),
db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: asc(schema.draftSlots.draftOrder),
}),
teamId
? db.query.draftQueue.findMany({
where: and(
eq(schema.draftQueue.teamId, teamId),
eq(schema.draftQueue.seasonId, seasonId)
),
orderBy: asc(schema.draftQueue.queuePosition),
})
: Promise.resolve([]),
teamId
? db.query.watchlist.findMany({
where: and(
eq(schema.watchlist.teamId, teamId),
eq(schema.watchlist.seasonId, seasonId)
),
})
: Promise.resolve([]),
]);
if (seasonData) {
// Compute overnight-pause state so reconnecting clients see it immediately.
let isOvernightPause = false;
let overnightResumesAt: number | undefined;
if (
draftSlots.length > 0 &&
seasonData.status === "draft" &&
!seasonData.draftPaused
) {
const { pickInRound } = calculatePickInfo(
seasonData.currentPickNumber ?? 1,
draftSlots.length
);
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (currentSlot) {
const result = await checkOvernightPause(seasonData, currentSlot.teamId);
isOvernightPause = result.active;
overnightResumesAt = result.resumesAtUTC;
}
}
socket.emit("draft-state-sync", {
currentPickNumber: seasonData.currentPickNumber || 1,
isPaused: seasonData.draftPaused || false,
status: seasonData.status,
isOvernightPause,
overnightResumesAt,
picks,
timers: timerRows.map((t) => {
const nowMs = Date.now();
const expiresAt = t.picksExpiresAt?.getTime();
const computedRemaining =
expiresAt && expiresAt > nowMs
? Math.max(0, Math.floor((expiresAt - nowMs) / 1000))
: t.timeRemaining;
return {
teamId: t.teamId,
timeRemaining: computedRemaining,
expiresAt: expiresAt && expiresAt > nowMs ? expiresAt : undefined,
};
}),
queue: teamId ? queueItems : undefined,
watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : undefined,
});
}
} catch (err) {
logger.error("[Socket] draft-state-sync query failed:", err);
// Non-fatal — the client will fall back to HTTP revalidation
}
});
socket.on("leave-draft", (seasonId: string) => {
if (!seasonId) return;
socket.leave(`draft-${seasonId}`);
logger.log(`Socket ${socket.id} left draft-${seasonId}`);
// Emit disconnection event if team was tracked
if (currentTeamId) {
socket.leave(`team-${currentTeamId}`);
// Remove team from connected teams tracking
const seasonConnectedTeams = connectedTeams.get(seasonId);
if (seasonConnectedTeams) {
seasonConnectedTeams.delete(currentTeamId);
logger.log(`Team ${currentTeamId} removed from tracking. Remaining: ${seasonConnectedTeams.size}`);
// Clean up empty sets
if (seasonConnectedTeams.size === 0) {
connectedTeams.delete(seasonId);
}
}
io?.to(`draft-${seasonId}`).emit("team-disconnected", { teamId: currentTeamId });
logger.log(`Team ${currentTeamId} disconnected from draft-${seasonId}`);
}
});
socket.on("test-event", (data: unknown) => {
logger.log("📨 Received test-event from client:", socket.id, data);
socket.emit("test-message", {
originalMessage: data,
serverResponse: "Hello from server!",
serverTimestamp: new Date().toISOString(),
socketId: socket.id,
});
logger.log("✅ Sent test-message response to client:", socket.id);
});
socket.on("disconnect", () => {
logger.log("Client disconnected:", socket.id);
// Emit disconnection event if team was tracked
if (currentTeamId && currentSeasonId) {
// Remove team from connected teams tracking
const seasonConnectedTeams = connectedTeams.get(currentSeasonId);
if (seasonConnectedTeams) {
seasonConnectedTeams.delete(currentTeamId);
logger.log(`Team ${currentTeamId} removed from tracking on disconnect. Remaining: ${seasonConnectedTeams.size}`);
// Clean up empty sets
if (seasonConnectedTeams.size === 0) {
connectedTeams.delete(currentSeasonId);
}
}
io?.to(`draft-${currentSeasonId}`).emit("team-disconnected", { teamId: currentTeamId });
logger.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`);
}
});
});
// Store globally for route handlers
global.__socketIO = io;
logger.log("Socket.IO initialized");
// Start the draft timer system (async import but don't await)
import("./timer").then(({ startDraftTimerSystem }) => {
startDraftTimerSystem();
}).catch((error) => {
logger.error("Failed to start timer system:", error);
});
// Start the daily snapshot system
import("./snapshots").then(({ startSnapshotSystem }) => {
startSnapshotSystem();
}).catch((error) => {
logger.error("Failed to start snapshot system:", error);
});
return io;
}
/**
* Get the Socket.IO server instance
*/
export function getSocketIO(): SocketIOServer {
const instance = io || global.__socketIO;
if (!instance) {
throw new Error("Socket.IO not initialized. Call initializeSocketIO first.");
}
return instance;
}