## Summary
- Removes the last in-process \`setInterval\` (\`server/snapshots.ts\` 24h loop) and replaces it with an external HTTP cron job via Forgejo Actions
- Adds automated standings sync + conditional simulation: syncs every 2h, only simulates when standings actually changed (detected by comparing \`gamesPlayed\`/\`leagueRank\` before upsert)
- Adds \`GET /healthz\` for Docker healthcheck (Phase B prerequisite)
## What's new
| Endpoint | Triggered by | What it does |
|---|---|---|
| \`POST /admin/jobs/run-daily-snapshots\` | Forgejo schedule \`5 0 * * *\` | Creates daily fantasy standings snapshots for all active/draft seasons |
| \`POST /admin/jobs/sync-and-simulate\` | Forgejo schedule \`0 */2 * * *\` | Syncs standings from external APIs; runs simulation only if standings changed |
| \`GET /healthz\` | Docker / Traefik | Returns 200 \`{ok:true}\` when DB reachable, 503 otherwise |
Both cron endpoints are protected by \`X-Cron-Secret\` header (set \`CRON_SECRET\` in Forgejo repo secrets + production env).
## Schema changes (migration 0118)
Two new nullable columns on \`sports_seasons\`:
- \`standings_last_changed_at\` — written by \`syncStandings()\` when data actually changes
- \`last_simulated_at\` — written by the cron job after a successful simulation run
## Deployment notes
1. Add \`CRON_SECRET\` to Forgejo repo secrets (generate with \`openssl rand -hex 32\`)
2. Add same value to production environment
3. Migration runs automatically via the \`migrate\` container on deploy
## Test plan
- [ ] \`curl -X POST https://brackt.com/admin/jobs/run-daily-snapshots -H "X-Cron-Secret: ..."\` → 200 \`{total, succeeded, errors}\`
- [ ] \`curl -X POST https://brackt.com/admin/jobs/sync-and-simulate -H "X-Cron-Secret: ..."\` → 200 with \`synced\`/\`unchanged\`/\`simulated\` breakdown
- [ ] \`curl https://brackt.com/healthz\` → 200 \`{ok:true}\`
- [ ] Verify Forgejo workflow runs appear in Actions tab after merge
- [ ] Kill web process mid-day; confirm external cron still fires (no in-process dependency)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #79
435 lines
16 KiB
TypeScript
435 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";
|
|
import { msToSeconds } from "~/lib/draft-timer";
|
|
|
|
// 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; timers?: Array<{ teamId: string; timeRemaining: number }> }) => 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
|
|
? msToSeconds(expiresAt - nowMs)
|
|
: 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);
|
|
});
|
|
|
|
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;
|
|
}
|