'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/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/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/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 }); } }); 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;