- Add POST /admin/jobs/run-daily-snapshots (replaces 24h setInterval in server/snapshots.ts) - Add POST /admin/jobs/sync-and-simulate (syncs standings every 2h; only simulates when standings actually changed by comparing gamesPlayed + leagueRank before upserting) - Add standingsLastChangedAt + lastSimulatedAt columns to sportsSeasons (migration 0118) - Add GET /healthz route for Docker healthcheck (pings DB, returns 503 on failure) - Add app/lib/cron-auth.ts shared X-Cron-Secret header auth helper - Remove startSnapshotSystem() from server/socket.ts (no more in-process setInterval) - Add Forgejo Actions scheduled workflows: daily-snapshots.yml (00:05 UTC) and sync-and-simulate.yml (every 2h) — require CRON_SECRET repo secret Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
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 }
|
|
);
|
|
}
|