Phase A: external HTTP cron jobs for snapshots, standings sync, and simulation #79
16 changed files with 6443 additions and 117 deletions
|
|
@ -26,3 +26,8 @@ TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||||
CLOUDINARY_CLOUD_NAME=""
|
CLOUDINARY_CLOUD_NAME=""
|
||||||
CLOUDINARY_API_KEY=""
|
CLOUDINARY_API_KEY=""
|
||||||
CLOUDINARY_API_SECRET=""
|
CLOUDINARY_API_SECRET=""
|
||||||
|
|
||||||
|
# Shared secret for cron job endpoints (POST /admin/jobs/*).
|
||||||
|
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
CRON_SECRET=""
|
||||||
|
|
|
||||||
14
.forgejo/workflows/daily-snapshots.yml
Normal file
14
.forgejo/workflows/daily-snapshots.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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"
|
||||||
14
.forgejo/workflows/sync-and-simulate.yml
Normal file
14
.forgejo/workflows/sync-and-simulate.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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"
|
||||||
7
app/lib/cron-auth.ts
Normal file
7
app/lib/cron-auth.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,11 @@ export default [
|
||||||
route("upcoming-events", "routes/upcoming-events.tsx"),
|
route("upcoming-events", "routes/upcoming-events.tsx"),
|
||||||
route("privacy-policy", "routes/privacy-policy.tsx"),
|
route("privacy-policy", "routes/privacy-policy.tsx"),
|
||||||
route("test-socket", "routes/test-socket.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
|
// Admin routes
|
||||||
route("admin", "routes/admin.tsx", [
|
route("admin", "routes/admin.tsx", [
|
||||||
|
|
|
||||||
29
app/routes/admin/jobs.run-daily-snapshots.ts
Normal file
29
app/routes/admin/jobs.run-daily-snapshots.ts
Normal 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 });
|
||||||
|
}
|
||||||
61
app/routes/admin/jobs.sync-and-simulate.ts
Normal file
61
app/routes/admin/jobs.sync-and-simulate.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
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;
|
||||||
|
|
||||||
|
// changed === true means syncStandings() detected new data this run, so simulate.
|
||||||
|
// Overlap is guarded inside runSportsSeasonSimulation (throws if simulationStatus === "running").
|
||||||
|
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
11
app/routes/healthz.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
import { findParticipantsBySportsSeasonId, updateParticipant } from "~/models/season-participant";
|
||||||
import { upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
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 toUpsert: Parameters<typeof upsertRegularSeasonStandings>[0] = [];
|
||||||
const unmatchedRecords: typeof fetchedRecords = [];
|
const unmatchedRecords: typeof fetchedRecords = [];
|
||||||
|
const matchedParticipantIds: string[] = [];
|
||||||
|
|
||||||
for (const record of fetchedRecords) {
|
for (const record of fetchedRecords) {
|
||||||
// Strategy A: try externalId-first (bypasses name matching entirely after first sync)
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
matchedParticipantIds.push(participant.id);
|
||||||
toUpsert.push({
|
toUpsert.push({
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
sportsSeasonId,
|
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) {
|
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);
|
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
|
// 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,
|
externalTeamId: r.externalTeamId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { synced: toUpsert.length, unmatched };
|
return { synced: toUpsert.length, unmatched, changed };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,5 @@ export interface UnmatchedTeam {
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
synced: number;
|
synced: number;
|
||||||
unmatched: UnmatchedTeam[];
|
unmatched: UnmatchedTeam[];
|
||||||
|
changed: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,9 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
eloMaxRating: integer("elo_max_rating").default(1750),
|
eloMaxRating: integer("elo_max_rating").default(1750),
|
||||||
// Simulation status (prevents concurrent simulation runs)
|
// Simulation status (prevents concurrent simulation runs)
|
||||||
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
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
|
// Controls the window during which this sport season appears in league creation/settings
|
||||||
draftOn: date("draft_on").notNull(),
|
draftOn: date("draft_on").notNull(),
|
||||||
draftOff: date("draft_off").notNull(),
|
draftOff: date("draft_off").notNull(),
|
||||||
|
|
|
||||||
4
drizzle/0118_chunky_vivisector.sql
Normal file
4
drizzle/0118_chunky_vivisector.sql
Normal 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");
|
||||||
6249
drizzle/meta/0118_snapshot.json
Normal file
6249
drizzle/meta/0118_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -827,6 +827,13 @@
|
||||||
"when": 1780727815228,
|
"when": 1780727815228,
|
||||||
"tag": "0117_fix_picks_expires_at",
|
"tag": "0117_fix_picks_expires_at",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 118,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780890669286,
|
||||||
|
"tag": "0118_chunky_vivisector",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import * as schema from "~/database/schema";
|
|
||||||
import { eq, or } from "drizzle-orm";
|
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
|
||||||
import { logger } from "./logger";
|
|
||||||
import { db } from "./db";
|
|
||||||
|
|
||||||
let snapshotInterval: NodeJS.Timeout | null = null;
|
|
||||||
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // Check once per day (in milliseconds)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the daily snapshot system
|
|
||||||
* Runs once per day to create snapshots for all active seasons
|
|
||||||
*/
|
|
||||||
export function startSnapshotSystem(): void {
|
|
||||||
if (snapshotInterval) {
|
|
||||||
logger.log("[Snapshots] Snapshot system already running");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run immediately on startup
|
|
||||||
void createDailySnapshots();
|
|
||||||
|
|
||||||
// Then run once per day
|
|
||||||
snapshotInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await createDailySnapshots();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[Snapshots] Error creating daily snapshots:", error);
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL);
|
|
||||||
|
|
||||||
logger.log("[Snapshots] Daily snapshot system started (runs once per day)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the snapshot system
|
|
||||||
*/
|
|
||||||
export function stopSnapshotSystem(): void {
|
|
||||||
if (snapshotInterval) {
|
|
||||||
clearInterval(snapshotInterval);
|
|
||||||
snapshotInterval = null;
|
|
||||||
logger.log("[Snapshots] Snapshot system stopped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create daily snapshots for all active seasons
|
|
||||||
* Only creates snapshots if they don't already exist for today
|
|
||||||
*/
|
|
||||||
async function createDailySnapshots(): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
||||||
|
|
||||||
logger.log(`[Snapshots] Checking for snapshots to create (${today})`);
|
|
||||||
|
|
||||||
// Get all seasons that are active or in draft (we want to track standings for these)
|
|
||||||
const activeSeasons = await db.query.seasons.findMany({
|
|
||||||
where: or(
|
|
||||||
eq(schema.seasons.status, "active"),
|
|
||||||
eq(schema.seasons.status, "draft")
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeSeasons.length === 0) {
|
|
||||||
logger.log("[Snapshots] No active seasons found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(`[Snapshots] Found ${activeSeasons.length} active season(s)`);
|
|
||||||
|
|
||||||
for (const season of activeSeasons) {
|
|
||||||
try {
|
|
||||||
await createDailySnapshot(season.id, db);
|
|
||||||
logger.log(`[Snapshots] ✅ Upserted snapshot for season ${season.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[Snapshots] Error creating snapshot for season ${season.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log("[Snapshots] Daily snapshot check complete");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger snapshot creation for specific seasons
|
|
||||||
* Useful for admin tools or manual triggers
|
|
||||||
*/
|
|
||||||
export async function createSnapshotsForSeasons(seasonIds: string[]): Promise<void> {
|
|
||||||
logger.log(`[Snapshots] Manual trigger for ${seasonIds.length} season(s)`);
|
|
||||||
|
|
||||||
for (const seasonId of seasonIds) {
|
|
||||||
try {
|
|
||||||
await createDailySnapshot(seasonId, db);
|
|
||||||
logger.log(`[Snapshots] ✅ Created snapshot for season ${seasonId}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[Snapshots] Error creating snapshot for season ${seasonId}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually trigger snapshot creation for all active seasons
|
|
||||||
* Useful for testing or manual refreshes
|
|
||||||
*/
|
|
||||||
export async function createSnapshotsForAllSeasons(): Promise<void> {
|
|
||||||
logger.log("[Snapshots] Manual trigger for all active seasons");
|
|
||||||
await createDailySnapshots();
|
|
||||||
}
|
|
||||||
|
|
@ -420,13 +420,6 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
logger.error("Failed to start timer system:", 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;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue