Phase A: external HTTP cron jobs for snapshots, standings sync, and simulation #79
14 changed files with 6451 additions and 9 deletions
15
.forgejo/workflows/daily-snapshots.yml
Normal file
15
.forgejo/workflows/daily-snapshots.yml
Normal 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
|
||||
15
.forgejo/workflows/sync-and-simulate.yml
Normal file
15
.forgejo/workflows/sync-and-simulate.yml
Normal 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
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("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", [
|
||||
|
|
|
|||
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 });
|
||||
}
|
||||
72
app/routes/admin/jobs.sync-and-simulate.ts
Normal file
72
app/routes/admin/jobs.sync-and-simulate.ts
Normal 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
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 { 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ export interface UnmatchedTeam {
|
|||
export interface SyncResult {
|
||||
synced: number;
|
||||
unmatched: UnmatchedTeam[];
|
||||
changed: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
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,
|
||||
"tag": "0117_fix_picks_expires_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 118,
|
||||
"version": "7",
|
||||
"when": 1780890669286,
|
||||
"tag": "0118_chunky_vivisector",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue