f33091ddb8
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows date and teams, never venue or final totals. But the grain is far cheaper than per-prop or even per-game: ONE statsapi schedule call per game DATE returns every game that day with venue, linescore and scoring plays. Fifteen games, one call, verified live. PUBLIC BASE — the ingestion was already done. The static FanGraphs table from Session 15 is the public base; this converts its 100-indexed values into the multipliers the composable architecture wants (Coors 128 becomes 1.28) rather than ingesting a second copy of a number we already hold. It is labelled COMMODITY in the code, not just in a comment. Every resolution carries a provenance record, and the public one reads proprietary: false with the note "Commodity: a public number. Not a VYNDR derivation." The proprietary label exists but belongs only to the self-derived version, and only once it beats this base on the instrument. A surface rendering a park effect can state which it is rather than implying the flattering one. Honest-absent where even the PUBLIC number is thin: a relocated club in a temporary venue gets no factor, because a public number for a park with one season behind it is no more trustworthy than ours would be. SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only accessor, public and derived return identical shapes, and callers never branch on source — so when self-derived factors clear their floor they swap into the same slot with nothing downstream to rewrite. A derived source with no factor available returns absent rather than silently falling back to public, because a silent fallback would make a proprietary claim out of a commodity number. GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain, deduped on game_id, never copied onto prop rows — a game's totals belong to the game, and duplicating them per prop is how one fact starts disagreeing with itself. Every field is tied to a named future derivation: venue for park factors, runs for the run environment, HR totals for HR factors. Nothing else is stored. Only Final games are captured, since an in-progress total is not a result, and a game with no scoring plays reports HR as absent rather than zero. HR totals come from scoring plays, which is complete because every home run scores at least the batter. The accrual target is stated rather than promised: 150 home games per venue at roughly 81 per season means about two seasons before a self-derived factor can be nominated, and accrualStatus() reports live progress per venue so the wait is measurable. Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the pitcher's home-runs-allowed at the same park, mirrored on the under; San Francisco negative; Tampa flagged weather-N/A with its factor still applying; the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19 capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate game ids. Migration 035. Induce with POST /api/internal/gamectx/:date, progress at /gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
505 lines
20 KiB
JavaScript
505 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Internal ops endpoints (Session 18).
|
|
*
|
|
* Reachable only with the shared `VYNDR_INTERNAL_KEY` — never
|
|
* exposed to end users. The admin dashboard wires the Tank01
|
|
* prefetch button to POST here through the Next.js server (the
|
|
* key never touches a browser).
|
|
*
|
|
* Deviation from spec: the spec suggested `execSync('node scripts/tank01-prefetch.js')`.
|
|
* We import the module instead — same behavior, but in-process and
|
|
* testable. The module already exposes `main(argv)` which returns
|
|
* the same summary object the spec expected to parse out of stdout.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { requireInternalAuth } = require('../middleware/internalAuth');
|
|
const tank01Prefetch = require('../../scripts/tank01-prefetch');
|
|
const quotaTracker = require('../services/quotaTracker');
|
|
|
|
const router = express.Router();
|
|
|
|
router.use(requireInternalAuth({ loopbackOnly: false }));
|
|
|
|
/**
|
|
* GET /api/internal/quota (Session 20)
|
|
*
|
|
* Snapshot of every configured provider's current quota counter.
|
|
* Consumed by the admin dashboard's "Provider Quotas" tile. Cached
|
|
* for 5s so a refresh-button mash doesn't flood Redis.
|
|
*/
|
|
router.get('/quota', async (req, res) => {
|
|
try {
|
|
const providers = await quotaTracker.getAllQuotaStatuses();
|
|
res.set('Cache-Control', 'private, max-age=5');
|
|
return res.json({ ok: true, providers });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/quota] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/quota/test-alert (quota guard)
|
|
*
|
|
* Test-fires the quota pager end-to-end through the REAL opsNotify path so
|
|
* "does the 80% alert actually deliver?" is verifiable on demand (the reason
|
|
* the 500/500 drain went unnoticed was a counting blind spot, not delivery —
|
|
* this proves the delivery leg). Sends one ntfy to vyndr-pipeline-kev2026.
|
|
* Does NOT touch the real counter. Body: { pct? } (default 0.85).
|
|
*/
|
|
router.post('/quota/test-alert', async (req, res) => {
|
|
try {
|
|
const notify = require('../utils/opsNotify').notify;
|
|
const pct = Number.isFinite(Number(req.body && req.body.pct)) ? Number(req.body.pct) : 0.85;
|
|
const out = await notify(
|
|
`TEST — odds-api quota alert delivery check (${Math.round(pct * 100)}%). If you can read this, the quota pager path works.`,
|
|
{ title: 'VYNDR quota (test)', priority: 'high', tags: ['warning', 'chart_decreasing'] },
|
|
);
|
|
return res.json({ ok: true, delivery: out });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/prefetch/tank01
|
|
*
|
|
* Body (all optional):
|
|
* { max?: number, sports?: string[]|string, dryRun?: boolean }
|
|
*
|
|
* Builds an argv array equivalent to the CLI form and hands it to
|
|
* the prefetch module. Returns the module's summary on success.
|
|
*/
|
|
router.post('/prefetch/tank01', async (req, res) => {
|
|
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
|
|
|
// Build argv. `main()` parses its own args, so all the validation
|
|
// (numeric bounds, allowed sports) stays in one place — we just
|
|
// translate JSON shapes into CLI flags.
|
|
const argv = ['node', 'scripts/tank01-prefetch.js'];
|
|
|
|
if (Number.isFinite(body.max) && body.max > 0) {
|
|
argv.push(`--max=${Math.floor(body.max)}`);
|
|
}
|
|
if (body.dryRun === true) {
|
|
argv.push('--dry-run');
|
|
}
|
|
if (body.sports) {
|
|
const sportsList = Array.isArray(body.sports)
|
|
? body.sports.join(',')
|
|
: String(body.sports);
|
|
argv.push(`--sports=${sportsList}`);
|
|
}
|
|
|
|
try {
|
|
const summary = await tank01Prefetch.main(argv);
|
|
return res.json({ ok: true, summary });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/prefetch/tank01] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/snapshot/:sport (Session 45)
|
|
*
|
|
* Trigger one snapshot cycle for a sport (pre-grade the slate, lock grades,
|
|
* compute deltas, emit ticker events). Internal-only (requireInternalAuth at the
|
|
* router root). This is what the cron / n8n schedule calls — never public, so a
|
|
* bad actor can't drain the PropLine quota by spamming it.
|
|
*/
|
|
/** POST /api/internal/snapshot/all — every active sport, sequentially.
|
|
* Registered BEFORE /snapshot/:sport so "all" isn't captured as a sport. */
|
|
router.post('/snapshot/all', async (req, res) => {
|
|
const snapshot = require('../services/snapshotService');
|
|
try {
|
|
const results = await snapshot.runAllSnapshots();
|
|
return res.json({ ok: true, results });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/snapshot/all] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/internal/snapshot/status (Session 52) — verification probe. Reports
|
|
* whether the in-process cron is armed, the freshest snapshot per sport, which
|
|
* pipeline Redis keys exist, and the ticker item count. Read-only; safe to poll.
|
|
* GET vs the POST /snapshot/:sport below — no route collision.
|
|
*/
|
|
router.get('/snapshot/status', async (req, res) => {
|
|
const { cacheGet } = require('../utils/redis');
|
|
const { HOURS_UTC, isSnapshotOverdue } = require('../snapshotScheduler');
|
|
const SPORTS = ['mlb', 'nba', 'wnba'];
|
|
try {
|
|
const redis_keys = {};
|
|
const last_snapshot = {};
|
|
for (const sp of SPORTS) {
|
|
const latestKey = `snapshot:${sp}:latest`;
|
|
const prevKey = `snapshot:${sp}:previous`;
|
|
const gradesKey = `grades:${sp}`;
|
|
const [latest, prev, grades] = await Promise.all([cacheGet(latestKey), cacheGet(prevKey), cacheGet(gradesKey)]);
|
|
redis_keys[latestKey] = !!latest;
|
|
redis_keys[prevKey] = !!prev;
|
|
redis_keys[gradesKey] = !!grades;
|
|
if (latest) {
|
|
last_snapshot[sp] = {
|
|
updated_at: latest.updated_at || null,
|
|
gradeCount: Array.isArray(latest.grades) ? latest.grades.length : 0,
|
|
deltaCount: Array.isArray(latest.deltas) ? latest.deltas.length : 0,
|
|
};
|
|
}
|
|
}
|
|
const ticker = await cacheGet('ticker:items');
|
|
redis_keys['ticker:items'] = !!ticker;
|
|
// Session 56 — surface the missed-cron signal in the health probe.
|
|
const mlbTs = last_snapshot.mlb && last_snapshot.mlb.updated_at;
|
|
return res.json({
|
|
cron_armed: process.env.SNAPSHOT_CRON === '1',
|
|
cron_hours_utc: HOURS_UTC,
|
|
last_snapshot,
|
|
overdue: isSnapshotOverdue(mlbTs),
|
|
redis_keys,
|
|
ticker_count: Array.isArray(ticker) ? ticker.length : 0,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/snapshot/:sport', async (req, res) => {
|
|
const snapshot = require('../services/snapshotService');
|
|
try {
|
|
const summary = await snapshot.runSnapshot(req.params.sport);
|
|
return res.json({ ok: true, summary });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/snapshot] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/outcomes/all (Session 55) — settle every sport's latest
|
|
* snapshot against real results + recompute the overall accuracy record. This
|
|
* is the self-learning loop's write path (the public /api/accuracy is read-only).
|
|
* Registered BEFORE /outcomes/:sport so "all" isn't captured as a sport.
|
|
*/
|
|
router.post('/outcomes/all', async (req, res) => {
|
|
const outcomes = require('../services/outcomeService');
|
|
try {
|
|
const results = await outcomes.settleAllOutcomes();
|
|
return res.json({ ok: true, results });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/outcomes/all] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's
|
|
* desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS
|
|
* ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost.
|
|
*/
|
|
router.post('/ghost/drafts', async (req, res) => {
|
|
try {
|
|
const { assembleDeskPack } = require('../services/deskService');
|
|
const ghost = require('../services/ghostPublisher');
|
|
const pack = await assembleDeskPack();
|
|
const result = await ghost.saveDailyDrafts(pack);
|
|
return res.json({ ok: result.ok, result });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/ghost/drafts] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday
|
|
* odds-only refresh: STEAM/VALUE movement, public revisions, closing
|
|
* capture. Same behavior as the in-process 20-min cadence.
|
|
*/
|
|
router.post('/refresh/all', async (req, res) => {
|
|
const refresh = require('../services/intradayRefreshService');
|
|
try {
|
|
const results = await refresh.runAllIntradayRefreshes();
|
|
return res.json({ ok: true, results });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/refresh/all] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/backup/run (Session 64) — fire ONE real database backup
|
|
* now: pg_dump → validate (`pg_restore --list` must contain ledger_entries) →
|
|
* off-box rsync to the Storage Box.
|
|
*
|
|
* Exists because the backup can only run where SUPABASE_DB_URL and the Supabase
|
|
* network route live — inside this container — and there was previously no way
|
|
* to trigger or observe it without a shell on the box. Returns the script's
|
|
* exit code and output tail so a real run can be VERIFIED, not assumed.
|
|
*/
|
|
router.post('/backup/run', async (req, res) => {
|
|
const { runBackup, durabilityWarning } = require('../backupScheduler');
|
|
try {
|
|
const started = Date.now();
|
|
const result = await runBackup();
|
|
// Session 64 Phase 2b — off-box is REQUIRED now, so its outcome is reported
|
|
// SEPARATELY from the exit code. The script deliberately still exits 0 on a
|
|
// failed push (a durable on-box dump must not raise a false total-failure
|
|
// alarm) — so without this field a failed off-box push reads as success.
|
|
const tail = result.tail || '';
|
|
const offboxOk = /OFFBOX_OK=1/.test(tail) ? true
|
|
: (/OFFBOX_OK=0/.test(tail) ? false : null); // null = deferred/not attempted
|
|
return res.json({
|
|
ok: result.ok,
|
|
offbox_ok: offboxOk,
|
|
exit_code: result.code,
|
|
duration_ms: Date.now() - started,
|
|
durability_warning: durabilityWarning() || null,
|
|
remote_configured: !!process.env.BACKUP_REMOTE,
|
|
ssh_key_configured: !!process.env.BACKUP_SSH_KEY,
|
|
output_tail: result.tail || result.error || null,
|
|
});
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/backup/run] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/internal/backup/verify (Session 64) — prove the newest dump on the
|
|
* persistent volume actually CONTAINS the data, by counting `ledger_entries`
|
|
* rows out of the archive with pg_restore. A backup nobody has read back is a
|
|
* hope, not a backup.
|
|
*/
|
|
router.get('/backup/verify', async (req, res) => {
|
|
const { latestDump, countRowsInDump } = require('../backupScheduler');
|
|
try {
|
|
const dir = process.env.BACKUP_DIR || '/var/backups/vyndr';
|
|
// Report identity + writability: a mounted-but-unwritable volume is the
|
|
// exact failure we hit (Coolify mounts root-owned; the container runs as
|
|
// the non-root `vyndr` user), and the fix needs the real uid/gid.
|
|
const fs = require('fs');
|
|
let writable = false;
|
|
let dirErr = null;
|
|
try {
|
|
fs.accessSync(dir, fs.constants.W_OK);
|
|
writable = true;
|
|
} catch (e) { dirErr = e.code || e.message; }
|
|
const identity = {
|
|
uid: typeof process.getuid === 'function' ? process.getuid() : null,
|
|
gid: typeof process.getgid === 'function' ? process.getgid() : null,
|
|
backup_dir_writable: writable,
|
|
backup_dir_error: dirErr,
|
|
};
|
|
|
|
const dump = latestDump(dir);
|
|
if (!dump) {
|
|
return res.json({ ok: false, backup_dir: dir, ...identity, error: 'no dump found in BACKUP_DIR' });
|
|
}
|
|
const table = String(req.query.table || 'ledger_entries');
|
|
const counted = await countRowsInDump(dump.path, table);
|
|
return res.json({
|
|
ok: counted.ok,
|
|
backup_dir: dir,
|
|
...identity,
|
|
dump: dump.file,
|
|
dump_bytes: dump.size,
|
|
table,
|
|
rows_in_dump: counted.rows,
|
|
error: counted.error || null,
|
|
});
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/backup/verify] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/internal/backup/offbox — list the dumps actually present ON the
|
|
* Storage Box. Exit 0 from the backup is not proof the file landed; this reads
|
|
* the remote directory back so off-box presence is verified, not inferred.
|
|
*/
|
|
router.get('/backup/offbox', async (req, res) => {
|
|
const { listOffbox } = require('../backupScheduler');
|
|
try {
|
|
const result = await listOffbox();
|
|
return res.json(result);
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/harness/run (Session 64) — induce the nightly harness run
|
|
* on demand. Scheduled mechanisms are verified by INDUCING their real code
|
|
* path, never by waiting for a slot to discover whether they work.
|
|
*/
|
|
router.post('/harness/run', async (req, res) => {
|
|
try {
|
|
const runner = require('../services/harnessRunner');
|
|
const out = await runner.runAndRecord();
|
|
return res.json({ ok: out.ok !== false, ...out, last_run_at: await runner.lastRunAt() });
|
|
} catch (err) {
|
|
return res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/closing/capture/:sport (Session 64) — induce one closing
|
|
* capture pass against the CURRENT live odds, so the mechanism is proven now
|
|
* rather than discovered tomorrow.
|
|
*/
|
|
router.post('/closing/capture/:sport', async (req, res) => {
|
|
try {
|
|
const sp = String(req.params.sport || '').toLowerCase();
|
|
const closing = require('../services/closingCapture');
|
|
const odds = await require('../services/oddsService').getOdds(sp);
|
|
const props = (odds && Array.isArray(odds.props)) ? odds.props : [];
|
|
await require('../services/gameBinder').attachGameTimes(sp, props, {});
|
|
const windowMinutes = Number(req.query.window || 0) || undefined;
|
|
const rows = closing.buildCaptureRows(sp, props, { windowMinutes });
|
|
const priced = rows.filter((r) => !r.missed_reason).length;
|
|
const persisted = req.query.dry === '1' ? { skipped: true } : await closing.persist(rows);
|
|
const reasons = {};
|
|
for (const r of rows) if (r.missed_reason) reasons[r.missed_reason] = (reasons[r.missed_reason] || 0) + 1;
|
|
return res.json({
|
|
ok: true, sport: sp, props: props.length,
|
|
rows: rows.length, priced, missed: rows.length - priced,
|
|
missed_reasons: reasons, persisted,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
|
|
* persistent ledger (outcome + actual_value + CLV) across every sport.
|
|
* Idempotent — safe to re-run; already-settled rows are never touched.
|
|
*/
|
|
router.post('/ledger/settle', async (req, res) => {
|
|
const ledger = require('../services/ledgerService');
|
|
try {
|
|
const results = await ledger.settleAllLedgers();
|
|
return res.json({ ok: true, results });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/ledger/settle] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/newsletter/send (Session S7, a1) — assemble today's
|
|
* VYNDR REPORT from the pipeline (snapshot signals + streak lens + the
|
|
* ledger record) and send it as a Listmonk campaign to the opted-in list.
|
|
*
|
|
* DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator
|
|
* (or a future n8n cron, once Kev arms it) triggers the send. Env-gated —
|
|
* without LISTMONK_* config it's a calm no-op; an empty report (zero
|
|
* signals AND zero streaks) refuses to send.
|
|
*
|
|
* Body (optional): { sports?: string[] } — defaults to ['mlb', 'wnba'].
|
|
*/
|
|
router.post('/newsletter/send', async (req, res) => {
|
|
const newsletter = require('../services/newsletterService');
|
|
const body = (req.body && typeof req.body === 'object') ? req.body : {};
|
|
const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined;
|
|
try {
|
|
const result = await newsletter.sendDailyReport({ sports });
|
|
return res.json(result);
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/newsletter/send] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
/** POST /api/internal/gamectx/:date — induce a game-context capture on demand. */
|
|
router.post('/gamectx/:date', async (req, res) => {
|
|
try {
|
|
const gc = require('../services/gameContext');
|
|
const out = await gc.captureDate(req.params.date, { sport: req.query.sport || 'mlb' });
|
|
return res.status(out.ok ? 200 : 500).json(out);
|
|
} catch (err) { return res.status(500).json({ ok: false, error: err.message }); }
|
|
});
|
|
|
|
/** GET /api/internal/gamectx/accrual — how far from a self-derived park factor. */
|
|
router.get('/gamectx/accrual', async (req, res) => {
|
|
try {
|
|
const gc = require('../services/gameContext');
|
|
return res.json(await gc.accrualStatus({ sport: req.query.sport || 'mlb' }));
|
|
} catch (err) { return res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
/**
|
|
* POST /api/internal/statcast/refresh — INDUCE the Layer-1 mechanism refresh.
|
|
*
|
|
* Same call the nightly scheduler makes. Exists so the job is verifiable ON
|
|
* DEMAND: we prove a refresh works by running it and reading the result, never
|
|
* by waiting for the cron slot. Idempotent — running it twice is a no-op beyond
|
|
* refreshing values and updated_at.
|
|
*/
|
|
router.post('/statcast/refresh', async (req, res) => {
|
|
try {
|
|
const agg = require('../services/statcastAggregateService');
|
|
const season = req.query.season ? Number(req.query.season) : undefined;
|
|
const out = await agg.refreshSeason({ season });
|
|
const fresh = await agg.getFreshness({});
|
|
return res.status(out.ok ? 200 : 500).json({ ...out, freshness: fresh, stale: agg.isStale(fresh) });
|
|
} catch (err) {
|
|
console.error('[internal/statcast]', err.message);
|
|
return res.status(500).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
/** GET /api/internal/statcast/status — freshness probe for the mechanism tier. */
|
|
router.get('/statcast/status', async (req, res) => {
|
|
try {
|
|
const agg = require('../services/statcastAggregateService');
|
|
const fresh = await agg.getFreshness({});
|
|
return res.json({
|
|
...fresh,
|
|
stale: agg.isStale(fresh),
|
|
max_age_hours: agg.MAX_AGE_HOURS,
|
|
min_pa: agg.MIN_PA,
|
|
min_ip: agg.MIN_IP,
|
|
cron_hour_utc: Number(process.env.STATCAST_HOUR_UTC || 11),
|
|
enabled: process.env.STATCAST !== '0',
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/outcomes/:sport', async (req, res) => {
|
|
const outcomes = require('../services/outcomeService');
|
|
try {
|
|
const summary = await outcomes.settleSnapshot(req.params.sport);
|
|
await outcomes.recomputeOverall();
|
|
return res.json({ ok: true, summary: { sport: summary.sport, settled: summary.settled, pending: summary.pending, accuracy: summary.accuracy } });
|
|
} catch (err) {
|
|
const message = err && err.message ? err.message : String(err);
|
|
console.error('[internal/outcomes] failed:', message);
|
|
return res.status(500).json({ ok: false, error: message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|