32d7200571
Email capture + daily report assembly + operator-triggered Listmonk send.
- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
(the real signup success surface); double-opt-in note; 'Signups open soon'
when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
server-side email validation, forwards to Listmonk subscribers API with
preconfirm_subscriptions:false (Listmonk sends the confirmation).
No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
ledgerService.getModelAggregate (percentage only when hit_pct != null —
n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
refuses an empty report. Deliberately UNSCHEDULED — only
POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.
Tests 2398 -> 2429 (207 suites, all green); next build exit 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
257 lines
9.8 KiB
JavaScript
257 lines
9.8 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/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;
|