Phase A: external HTTP cron jobs for snapshots, standings sync, and simulation #79

Merged
chrisp merged 3 commits from feat/phase-a-cron-jobs into main 2026-06-08 07:42:15 +00:00
14 changed files with 6451 additions and 9 deletions
Showing only changes of commit 85bfc657e5 - Show all commits

View file

@ -0,0 +1,15 @@
name: Daily snapshots
on:
schedule:
- cron: '5 0 * * *'
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Create daily standings snapshots
run: |
curl -sf -X POST https://brackt.com/admin/jobs/run-daily-snapshots \
-H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \
-H "Content-Type: application/json" \
| cat

View file

@ -0,0 +1,15 @@
name: Sync standings and simulate
on:
schedule:
- cron: '0 */2 * * *'
jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Sync standings and run simulations where changed
run: |
curl -sf -X POST https://brackt.com/admin/jobs/sync-and-simulate \
-H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \
-H "Content-Type: application/json" \
| cat

7
app/lib/cron-auth.ts Normal file
View file

@ -0,0 +1,7 @@
export function requireCronSecret(request: Request): void {
const secret = process.env.CRON_SECRET;
if (!secret) throw new Response("CRON_SECRET not configured", { status: 500 });
if (request.headers.get("x-cron-secret") !== secret) {
throw new Response("Unauthorized", { status: 401 });
}
}

View file

@ -74,6 +74,11 @@ export default [
route("upcoming-events", "routes/upcoming-events.tsx"),
route("privacy-policy", "routes/privacy-policy.tsx"),
route("test-socket", "routes/test-socket.tsx"),
route("healthz", "routes/healthz.ts"),
// Cron job endpoints
route("admin/jobs/run-daily-snapshots", "routes/admin/jobs.run-daily-snapshots.ts"),
route("admin/jobs/sync-and-simulate", "routes/admin/jobs.sync-and-simulate.ts"),
// Admin routes
route("admin", "routes/admin.tsx", [

View file

@ -0,0 +1,29 @@
import { database } from "~/database/context";
import { eq, or } from "drizzle-orm";
import * as schema from "~/database/schema";
import { createDailySnapshot } from "~/models/standings";
import { requireCronSecret } from "~/lib/cron-auth";
export async function action({ request }: { request: Request }) {
requireCronSecret(request);
const db = database();
const activeSeasons = await db.query.seasons.findMany({
where: or(eq(schema.seasons.status, "active"), eq(schema.seasons.status, "draft")),
});
let succeeded = 0;
const errors: { id: string; error: string }[] = [];
for (const season of activeSeasons) {
try {
await createDailySnapshot(season.id, db);
succeeded++;
} catch (err) {
errors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
}
}
const status = errors.length > 0 && succeeded === 0 ? 500 : 200;
return Response.json({ total: activeSeasons.length, succeeded, errors }, { status });
}

View file

@ -0,0 +1,72 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findSportsSeasonsByStatus } from "~/models/sports-season";
import { syncStandings } from "~/services/standings-sync";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import { requireCronSecret } from "~/lib/cron-auth";
export async function action({ request }: { request: Request }) {
requireCronSecret(request);
const activeSeasons = await findSportsSeasonsByStatus("active");
const synced: string[] = [];
const unchanged: string[] = [];
const simulated: string[] = [];
const syncErrors: { id: string; error: string }[] = [];
const simErrors: { id: string; error: string }[] = [];
for (const season of activeSeasons) {
let changed = false;
try {
const result = await syncStandings(season.id);
changed = result.changed;
if (changed) {
synced.push(season.id);
} else {
unchanged.push(season.id);
}
} catch (err) {
// Sports without a sync adapter (F1, IndyCar, etc.) throw — skip gracefully
syncErrors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
continue;
}
if (!changed) continue;
// Only simulate if standings changed since last simulation
const current = await database()
.select({ standingsLastChangedAt: schema.sportsSeasons.standingsLastChangedAt, lastSimulatedAt: schema.sportsSeasons.lastSimulatedAt })
.from(schema.sportsSeasons)
.where(eq(schema.sportsSeasons.id, season.id))
.then((rows) => rows[0]);
const needsSim =
current?.standingsLastChangedAt &&
(!current.lastSimulatedAt || current.standingsLastChangedAt > current.lastSimulatedAt);
if (!needsSim) continue;
try {
await runSportsSeasonSimulation(season.id);
await database()
.update(schema.sportsSeasons)
.set({ lastSimulatedAt: new Date() })
.where(eq(schema.sportsSeasons.id, season.id));
simulated.push(season.id);
} catch (err) {
// Seasons without a simulator config or that fail readiness checks throw — skip gracefully
simErrors.push({ id: season.id, error: err instanceof Error ? err.message : String(err) });
}
}
const hasErrors = syncErrors.length > 0 || simErrors.length > 0;
const hasSuccess = synced.length > 0 || unchanged.length > 0;
const status = hasErrors && !hasSuccess ? 500 : 200;
return Response.json(
{ synced, unchanged, simulated, syncErrors, simErrors },
{ status }
);
}

11
app/routes/healthz.ts Normal file
View file

@ -0,0 +1,11 @@
import { database } from "~/database/context";
import { sql } from "drizzle-orm";
export async function loader() {
try {
await database().execute(sql`SELECT 1`);
return Response.json({ ok: true });
} catch {
return Response.json({ ok: false }, { status: 503 });
}
}

View file

@ -1,5 +1,5 @@
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import * as schema from "~/database/schema";
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
@ -90,6 +90,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
const toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
const unmatchedRecords: typeof fetchedRecords = [];
const matchedParticipantIds: string[] = [];
for (const record of fetchedRecords) {
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
@ -112,6 +113,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
continue;
}
matchedParticipantIds.push(participant.id);
toUpsert.push({
participantId: participant.id,
sportsSeasonId,
@ -141,8 +143,37 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
});
}
// Detect whether standings actually changed by comparing gamesPlayed + leagueRank
// against current DB values before upserting.
let changed = false;
if (toUpsert.length > 0) {
const currentRows = await db
.select({
participantId: schema.regularSeasonStandings.participantId,
gamesPlayed: schema.regularSeasonStandings.gamesPlayed,
leagueRank: schema.regularSeasonStandings.leagueRank,
})
.from(schema.regularSeasonStandings)
.where(inArray(schema.regularSeasonStandings.participantId, matchedParticipantIds));
const currentByParticipant = new Map(
currentRows.map((r) => [r.participantId, r])
);
changed = toUpsert.some((row) => {
const current = currentByParticipant.get(row.participantId);
if (!current) return true; // new row
return current.gamesPlayed !== row.gamesPlayed || current.leagueRank !== row.leagueRank;
});
await upsertRegularSeasonStandings(toUpsert);
if (changed) {
await db
.update(schema.sportsSeasons)
.set({ standingsLastChangedAt: new Date() })
.where(eq(schema.sportsSeasons.id, sportsSeasonId));
}
}
// Persist unmatched records so admin can resolve them without losing data on page reload
@ -155,5 +186,5 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
externalTeamId: r.externalTeamId,
}));
return { synced: toUpsert.length, unmatched };
return { synced: toUpsert.length, unmatched, changed };
}

View file

@ -36,4 +36,5 @@ export interface UnmatchedTeam {
export interface SyncResult {
synced: number;
unmatched: UnmatchedTeam[];
changed: boolean;
}

View file

@ -397,6 +397,9 @@ export const sportsSeasons = pgTable("sports_seasons", {
eloMaxRating: integer("elo_max_rating").default(1750),
// Simulation status (prevents concurrent simulation runs)
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
// Timestamps for automated sync-and-simulate job (change detection)
standingsLastChangedAt: timestamp("standings_last_changed_at"),
lastSimulatedAt: timestamp("last_simulated_at"),
// Controls the window during which this sport season appears in league creation/settings
draftOn: date("draft_on").notNull(),
draftOff: date("draft_off").notNull(),

View file

@ -0,0 +1,4 @@
ALTER TABLE "sports_seasons" ADD COLUMN "standings_last_changed_at" timestamp;--> statement-breakpoint
ALTER TABLE "sports_seasons" ADD COLUMN "last_simulated_at" timestamp;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "season_template_sports_template_sport_unique" ON "season_template_sports" USING btree ("template_id","sport_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "season_templates_name_unique" ON "season_templates" USING btree ("name");

File diff suppressed because it is too large Load diff

View file

@ -827,6 +827,13 @@
"when": 1780727815228,
"tag": "0117_fix_picks_expires_at",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1780890669286,
"tag": "0118_chunky_vivisector",
"breakpoints": true
}
]
}

View file

@ -420,13 +420,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
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;
}