Session 14: Africa checkout, Tank01 NBA/MLB wiring, WNBA+MLB odds proxies, OAuth icons, loading skeletons (1330 tests)

This commit is contained in:
Kev
2026-06-11 10:06:49 -04:00
parent 10159209fa
commit f5d79cf70d
22 changed files with 979 additions and 27 deletions
+33
View File
@@ -108,6 +108,39 @@ router.get('/nba', async (req, res) => {
}
});
// Session 14 — WNBA + MLB. Same pattern as /nba: validate query,
// fetch via cached oddsService, project to the {sport, props}
// envelope the Slate consumes. odds-api may return empty during
// off-season; we still return 200 with an empty `props` array so
// the Slate can render its empty-state UX.
function buildSportRoute(sport) {
return async (req, res) => {
const errors = validateQueryParams(req.query);
if (errors.length > 0) {
return res.status(400).json({ error: errors.join('; ') });
}
try {
const result = await getOdds(sport);
const filtered = filterProps(result.props || [], req.query);
const props = groupProps(filtered);
if (result.stale) res.set('X-VYNDR-Stale', 'true');
return res.json({
sport,
updated_at: result.updated_at,
source: result.source,
quota_remaining: result.quota_remaining,
props,
});
} catch (err) {
const status = err.statusCode || 500;
return res.status(status).json({ error: err.message });
}
};
}
router.get('/wnba', buildSportRoute('wnba'));
router.get('/mlb', buildSportRoute('mlb'));
router.get('/ncaab', async (req, res) => {
if (!isNcaabSeason()) {
return res.json({
+16 -2
View File
@@ -14,8 +14,12 @@ const router = express.Router();
router.post('/checkout', requireAuth, async (req, res) => {
const { tier, founder_code } = req.body;
if (!tier || !['analyst', 'desk'].includes(tier)) {
return res.status(400).json({ error: 'tier must be "analyst" or "desk"' });
// Session 14 — 'africa' joins the validation whitelist. Whether
// the checkout succeeds for 'africa' depends on STRIPE_PRICE_AFRICA
// being set (see stripeService.PRICE_UNCONFIGURED handling); when
// it isn't, the service throws a 503 the catch block surfaces.
if (!tier || !['africa', 'analyst', 'desk'].includes(tier)) {
return res.status(400).json({ error: 'tier must be "africa", "analyst" or "desk"' });
}
try {
@@ -23,6 +27,16 @@ router.post('/checkout', requireAuth, async (req, res) => {
return res.json(result);
} catch (err) {
console.error('[VYNDR] Checkout error:', err.message);
// Session 14 — surface the "tier valid but Stripe price not
// provisioned yet" case with the explicit message + 503. This
// path is what the Africa-tier user hits until
// STRIPE_PRICE_AFRICA is configured in Coolify.
if (err && err.code === 'tier_unconfigured') {
return res.status(503).json({
error: err.message || 'Tier pricing not configured yet.',
code: 'tier_unconfigured',
});
}
return res.status(503).json({ error: 'Checkout creation failed' });
}
});
@@ -32,6 +32,11 @@ const gameLogService = require('./gameLogService');
// Session 7j — soccer branch. The extractor reads from prefetched
// Redis cache; no external HTTP on the user request path.
const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtractor');
// Session 14 — Tank01 augmentor. Reads cache keys the Tank01
// adapters write; no network from this path. Daily prefetch (future)
// populates the cache. Until that lands, the augmentor returns
// empty objects and the existing ESPN-derived features stand alone.
const tank01Augment = require('./tank01Augment');
const HTTP_TIMEOUT_MS = 8_000;
@@ -188,6 +193,37 @@ async function computeFeaturesForProp(rawProp = {}) {
errors.push('no_features_computed');
}
// Session 14 — Tank01 augmentation. Sport-specific. Both calls are
// cache-only (no network), Promise.allSettled-style isolated so a
// Redis hiccup on the Tank01 read doesn't fail the whole grade.
// The `t01_*` fields land alongside the ESPN-derived features;
// grading + reasoning + trap detection read them when present and
// ignore them when absent.
const ymd = new Date().toISOString().slice(0, 10).replace(/-/g, '');
try {
if (sport === 'nba') {
const aug = await tank01Augment.augmentNbaFeatures({
gameId: game?.gameId ?? null,
playerName: player,
ymd,
});
Object.assign(features, aug);
} else if (sport === 'mlb') {
const aug = await tank01Augment.augmentMlbFeatures({
gameId: game?.gameId ?? null,
batterName: player,
// batterId/pitcherId/pitcherName not yet plumbed through
// computeFeatures — the augmentor returns name-only markers
// when IDs are absent.
ymd,
});
Object.assign(features, aug);
}
} catch (err) {
// Never let augmentation failure poison the grade.
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
}
const trap = await safeGetTrap({
playerName: player,
statType,
+201
View File
@@ -0,0 +1,201 @@
/**
* Tank01 feature augmentor (Session 14).
*
* Reads cache keys the Tank01 adapters write — never hits the network
* directly. This keeps the user request path off the RapidAPI budget
* (1000 req/mo on the free tier) and makes the seam explicit: a
* future daily prefetch populates these keys, and `computeFeatures`
* reads them through this module.
*
* Contract: every export returns a flat object suitable for
* `Object.assign(features, augmentation)` — no nested structures, no
* throws, no DB writes. An empty object on miss is the correct
* "absent signal" response (the grading engine treats unknown
* features as neutral, not penalizing).
*
* Cache keys consumed (written by the adapters in Session 9):
* tank01:nba:boxscore:{gameId} — per-game player stats
* tank01:nba:games:{ymd} — daily schedule with statuses
* tank01:nba:odds:{ymd} — book-by-book market lines
* tank01:mlb:boxscore:{gameId} — per-game batter + pitcher lines
* tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchup
* tank01:mlb:scoreboard:{ymd} — daily slate
*
* Player matching: Tank01 box scores key players by ESPN-ish IDs.
* Without a Tank01-id → name index, we match by case-insensitive
* `longName`. Best-effort — if the prefetch eventually writes an
* index keyed by `tank01:player_by_name:{normalizedName}`, we'll
* prefer that.
*/
const { cacheGet } = require('../../utils/redis');
const { normalizeName } = require('../../utils/normalize');
function nameMatches(a, b) {
if (!a || !b) return false;
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
}
async function safeRead(key) {
try {
return await cacheGet(key);
} catch {
return null;
}
}
// Find a player row inside a Tank01 NBA box score response. The
// adapter projects `{playerId, name, team, pts, reb, ast, ...}` so
// we walk the projected list rather than the raw Tank01 envelope.
function findPlayerInBoxScore(boxScoreList, playerName) {
if (!Array.isArray(boxScoreList)) return null;
for (const row of boxScoreList) {
if (nameMatches(row.name, playerName)) return row;
}
return null;
}
// Find this player's market line + market average for the requested
// stat type, when Tank01's odds payload includes them.
function extractMarketLine(oddsPayload, _playerName, _statType) {
if (!oddsPayload || typeof oddsPayload !== 'object') return null;
// Tank01's odds schema isn't fully stable across versions; the
// adapter passes the raw body through. Surface the upstream
// payload under a namespaced key so downstream features can dig
// when the schema firms up; for now, just confirm presence.
return oddsPayload.body || oddsPayload || null;
}
/**
* augmentNbaFeatures — merge Tank01 NBA cache reads into the
* computeFeatures pipeline.
*
* @param {Object} params { gameId, playerName, ymd }
* gameId — ESPN game ID (preferred Tank01 key) or null
* playerName — display name to match against box scores
* ymd — YYYYMMDD for the daily odds/schedule lookups
* @returns {Promise<Object>} flat feature additions (possibly empty)
*/
async function augmentNbaFeatures({ gameId, playerName, ymd } = {}) {
const out = {};
if (!playerName && !gameId) return out;
// 1) Per-game box score (live mid-game, final post-game).
if (gameId) {
const box = await safeRead(`tank01:nba:boxscore:${gameId}`);
if (Array.isArray(box) && playerName) {
const row = findPlayerInBoxScore(box, playerName);
if (row) {
// Surface as `t01_*` so the grading engine + reasoning
// builder can distinguish Tank01-sourced fields from ESPN-
// sourced ones (audit trail in production logs).
out.t01_minutes = row.mins ?? null;
out.t01_pts = row.pts ?? null;
out.t01_reb = row.reb ?? null;
out.t01_ast = row.ast ?? null;
out.t01_threes = row.threes ?? null;
out.t01_blk = row.blk ?? null;
out.t01_stl = row.stl ?? null;
out.t01_tov = row.tov ?? null;
out.t01_final = !!row._final;
out.t01_source = 'tank01';
}
}
}
// 2) Market odds for the slate date — exposes consensus lines that
// the trap detector can read alongside odds-api numbers.
if (ymd) {
const odds = await safeRead(`tank01:nba:odds:${ymd}`);
if (odds) {
const market = extractMarketLine(odds, playerName);
if (market) out.t01_market_present = true;
}
}
return out;
}
/**
* augmentMlbFeatures — merge Tank01 MLB cache reads into computeFeatures.
*
* BvP is the headline signal: a batter's historical line against a
* specific pitcher. Pitcher resolution is best-effort — we check the
* daily scoreboard for the opposing starter when `pitcherName` isn't
* supplied by the caller.
*/
async function augmentMlbFeatures({ gameId, batterName, pitcherName, batterId, pitcherId, ymd } = {}) {
const out = {};
if (!batterName && !gameId) return out;
// 1) Per-game box score — live batter line during play, final after.
if (gameId) {
const box = await safeRead(`tank01:mlb:boxscore:${gameId}`);
if (Array.isArray(box) && batterName) {
const row = box.find((r) => r.role === 'batter' && nameMatches(r.name, batterName));
if (row) {
out.t01_box_present = true;
out.t01_team = row.team;
out.t01_final = !!row._final;
}
}
}
// 2) Batter-vs-pitcher historical line. Tank01 keys BvP by IDs.
// Without IDs, fall through silently — name-based BvP lookup
// needs a separate name→id index the prefetch can build.
if (batterId && pitcherId) {
const bvp = await safeRead(`tank01:mlb:bvp:${batterId}:${pitcherId}`);
if (bvp && typeof bvp === 'object' && !Array.isArray(bvp)) {
out.t01_bvp_pa = bvp.plateAppearances ?? 0;
out.t01_bvp_ab = bvp.atBats ?? 0;
out.t01_bvp_h = bvp.hits ?? 0;
out.t01_bvp_hr = bvp.homeRuns ?? 0;
out.t01_bvp_rbi = bvp.rbi ?? 0;
out.t01_bvp_so = bvp.strikeouts ?? 0;
out.t01_bvp_avg = bvp.avg;
out.t01_bvp_ops = bvp.ops;
// K-rate as a friendly derived signal — trap detection reads
// strikeout-prone matchups directly off this.
if (out.t01_bvp_ab > 0) {
out.t01_bvp_so_rate = Math.round((out.t01_bvp_so / out.t01_bvp_ab) * 1000) / 1000;
}
out.t01_source = 'tank01';
}
} else if (batterName && pitcherName) {
// Future: if a name→id index lands in the prefetch, resolve
// here. For now we drop a marker so reasoning can say
// "BvP unavailable — names not yet indexed."
out.t01_bvp_name_only = true;
out.t01_bvp_batter_name = batterName;
out.t01_bvp_pitcher_name = pitcherName;
}
// 3) Daily scoreboard — read the opposing pitcher when the caller
// didn't pass one. This is the "best-effort pitcher detection"
// the spec called out.
if (!pitcherName && !pitcherId && ymd && batterName) {
const slate = await safeRead(`tank01:mlb:scoreboard:${ymd}`);
if (Array.isArray(slate) && slate.length > 0) {
// We don't have a batter→team map here (that lives in the
// box score we may not have hit yet). Mark the slate as
// present so reasoning can say "starting pitcher data
// available — call augmentMlbFeatures with pitcherName."
out.t01_slate_present = true;
}
}
return out;
}
module.exports = {
augmentNbaFeatures,
augmentMlbFeatures,
__internals: {
nameMatches,
safeRead,
findPlayerInBoxScore,
extractMarketLine,
normalizeName,
},
};
+5
View File
@@ -12,6 +12,11 @@ const CACHE_TTL = 900; // 15 minutes in seconds
const SPORT_KEYS = {
nba: 'basketball_nba',
ncaab: 'basketball_ncaab',
// Session 14 — WNBA + MLB. odds-api may not always carry WNBA props
// (off-season returns empty); the route layer surfaces an empty
// array with a friendly message in that case.
wnba: 'basketball_wnba',
mlb: 'baseball_mlb',
// Soccer (Session 7j) — odds-api sport keys verified against
// https://the-odds-api.com/sports-odds-data/sports-apis.html
soccer_wc: 'soccer_fifa_world_cup',
+32
View File
@@ -12,8 +12,19 @@ const PRICE_MAP = {
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
// Session 14 — Africa tier ($4.99/mo). The Stripe product must be
// created in the dashboard before STRIPE_PRICE_AFRICA carries a real
// ID. Until then `getPriceId('africa')` returns a sentinel that
// surfaces a clean error to the user via the route handler.
africa: process.env.STRIPE_PRICE_AFRICA || null,
};
// Sentinel marker — getPriceId returns this when the tier is valid
// but the Stripe price hasn't been provisioned yet. The route layer
// checks for it and returns 503 with a friendly message rather than
// passing "null" to Stripe.
const PRICE_UNCONFIGURED = '__unconfigured__';
// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
// codes distributed before the rebrand keep redeeming during the transition.
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,VYNDR,BETONBLK,EARLYBIRD').split(',');
@@ -29,12 +40,30 @@ function getPriceId(tier, founderCode) {
const isFounder = isFounderCodeValid(founderCode);
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
if (tier === 'africa') {
// Africa tier doesn't have a founder discount — it IS the
// discount. Returns the sentinel when STRIPE_PRICE_AFRICA is
// unset so the route handler can produce a clean error instead
// of forwarding a null price ID to Stripe.
return PRICE_MAP.africa || PRICE_UNCONFIGURED;
}
throw new Error(`Invalid tier: ${tier}`);
}
async function createCheckoutSession(userId, email, tier, founderCode) {
const supabase = getSupabaseServiceClient();
const priceId = getPriceId(tier, founderCode);
// Session 14 — tier is valid but the upstream Stripe product
// hasn't been provisioned (most common case: africa before
// STRIPE_PRICE_AFRICA is configured in Coolify). Surface a clean
// 503 with `code: 'tier_unconfigured'` instead of letting null
// propagate to Stripe.
if (priceId === PRICE_UNCONFIGURED) {
const err = new Error(`Pricing for "${tier}" is not configured yet.`);
err.code = 'tier_unconfigured';
err.statusCode = 503;
throw err;
}
const isFounder = isFounderCodeValid(founderCode);
// Get or create Stripe customer
@@ -248,4 +277,7 @@ module.exports = {
constructWebhookEvent,
isFounderCodeValid,
getPriceId,
// Session 14 — exposed so the route layer + tests can recognize
// the "tier valid but Stripe price not provisioned" state.
PRICE_UNCONFIGURED,
};