The playoff_matches.isScoring column defaults to true in the database. Brackets created before this column was added (or before the migration set correct values) have isScoring=true on play-in rounds that should be false. When recalculate-floors replayed those matches, it took the "scoring round" path; since "Play-In Round 1" isn't in ROUND_CONFIG, config===null, and the loser was assigned finalPosition=0 regardless of loserAdvances — permanently eliminating teams like the Suns who had a second play-in game remaining. Fix: build a round→isScoring lookup from the bracket template before replaying matches and use it as the source of truth, falling back to the DB field only when the template doesn't define the round. This ensures non-scoring play-in rounds are always processed with isScoring=false so the loserAdvances guard fires correctly. Also revert unrelated socket changes from the previous (wrong) commit. https://claude.ai/code/session_01D3NbnVQdaH82Fk7Jm8y3sB
331 lines
12 KiB
TypeScript
331 lines
12 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 { 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;
|
|
"timer-update": (data: {
|
|
seasonId: string;
|
|
teamId: string;
|
|
timeRemaining: number;
|
|
currentPickNumber: number;
|
|
}) => void;
|
|
"autodraft-updated": (data: {
|
|
teamId: string;
|
|
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;
|
|
"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;
|
|
"draft-state-sync": (data: {
|
|
currentPickNumber: number;
|
|
isPaused: boolean;
|
|
status: string;
|
|
picks: Array<{
|
|
id: string;
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
timeUsed: number;
|
|
team: unknown;
|
|
participant: unknown;
|
|
sport: unknown;
|
|
}>;
|
|
timers: Array<{
|
|
teamId: string;
|
|
timeRemaining: number;
|
|
}>;
|
|
queue?: Array<{
|
|
id: string;
|
|
teamId: string;
|
|
seasonId: string;
|
|
participantId: string;
|
|
queuePosition: number;
|
|
}>;
|
|
}) => 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;
|
|
|
|
// 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>
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
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, queueItems] = 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.participants,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.participants,
|
|
eq(schema.draftPicks.participantId, schema.participants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.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),
|
|
}),
|
|
teamId
|
|
? db.query.draftQueue.findMany({
|
|
where: and(
|
|
eq(schema.draftQueue.teamId, teamId),
|
|
eq(schema.draftQueue.seasonId, seasonId)
|
|
),
|
|
orderBy: asc(schema.draftQueue.queuePosition),
|
|
})
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
if (seasonData) {
|
|
socket.emit("draft-state-sync", {
|
|
currentPickNumber: seasonData.currentPickNumber || 1,
|
|
isPaused: seasonData.draftPaused || false,
|
|
status: seasonData.status,
|
|
picks,
|
|
timers: timerRows.map((t) => ({
|
|
teamId: t.teamId,
|
|
timeRemaining: t.timeRemaining,
|
|
})),
|
|
queue: teamId ? queueItems : 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;
|
|
}
|