const express = require('express'); // ARCH-1 FIXED (Session 7g): /api/analyze grades through engine1 via // the unified analyzeViaEngine1 helper (computeFeatures → engine1 → // gradeAdapter). Response shape stays legacy-compatible — DemoScan // reads grade / confidence / kill_conditions_triggered / // reasoning.{summary,steps} without any change. Rate limit (SEC-1) // and Redis cache (PERF-1) both preserved. const { analyzeViaEngine1 } = require('../services/intelligence/analyzeViaEngine1'); const { cacheGet, cacheSet } = require('../utils/redis'); const { createRateLimit } = require('../middleware/rateLimit'); const { scanLimit } = require('../middleware/scanLimit'); const { applyTierGating } = require('../utils/tierGating'); const router = express.Router(); // SEC-1 (Session 7d): /prop and /batch are public — both proxy to the // prop analyzer which makes upstream API calls. Cap to 10 requests per // IP per minute so a single bad actor can't burn analyzer credits. const analyzeLimit = createRateLimit({ windowMs: 60_000, max: 10 }); router.use(analyzeLimit); // Tier scan-limit (this session): per-tier daily quota. Free users // (including anonymous demo via IP) are capped at 3 scans/day. Paid // tiers get higher caps from src/config/tiers.js. router.use(scanLimit()); // PERF-1 (Session 7d): cache analyze results in Redis. Same prop hit // twice within 60s reuses the previous analysis instead of re-doing the // upstream chain. 60s is short enough that line moves still surface. const ANALYZE_TTL_SECONDS = 60; function analyzeCacheKey(prop) { const sport = (prop.sport || 'nba').toLowerCase(); const player = String(prop.player || '').trim().toLowerCase(); const stat = String(prop.stat_type || '').trim().toLowerCase(); const line = Number(prop.line); const direction = String(prop.direction || '').toLowerCase(); return `analyze:${sport}:${player}:${stat}:${line}:${direction}`; } async function cachedAnalyze(prop) { const key = analyzeCacheKey(prop); const cached = await cacheGet(key); if (cached) return { ...cached, _cache: 'HIT' }; const result = await analyzeViaEngine1(prop); // cacheSet swallows failures (degraded Redis) — analysis still flows // even when the cache is down. await cacheSet(key, result, ANALYZE_TTL_SECONDS); return { ...result, _cache: 'MISS' }; } const VALID_STAT_TYPES = new Set([ 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers', ]); const VALID_DIRECTIONS = new Set(['over', 'under']); function validateProp(prop) { const errors = []; if (!prop.player) errors.push('player is required'); if (!prop.stat_type) errors.push('stat_type is required'); if (prop.stat_type && !VALID_STAT_TYPES.has(prop.stat_type)) { errors.push(`Invalid stat_type: ${prop.stat_type}`); } if (prop.line == null) errors.push('line is required'); if (!prop.direction) errors.push('direction is required'); if (prop.direction && !VALID_DIRECTIONS.has(prop.direction)) { errors.push(`Invalid direction: ${prop.direction}`); } return errors; } router.post('/prop', async (req, res) => { const errors = validateProp(req.body); if (errors.length > 0) { return res.status(400).json({ error: errors.join('; ') }); } try { const result = await cachedAnalyze(req.body); // Tier gating — unauthenticated demo callers and free-tier users // get the grade + confidence + edge_pct, but the reasoning and // kill condition details are redacted. Paid tiers pass through // untouched. const gated = applyTierGating(result, req.user?.tier || 'free'); return res.json(gated); } catch (err) { if (err.response && err.response.status === 404) { return res.status(404).json({ error: `Player not found: ${req.body.player}` }); } if (err.statusCode === 429 || err.statusCode === 503) { return res.status(err.statusCode).json({ error: err.message }); } console.error('[VYNDR] Analysis error:', err.message); return res.status(503).json({ error: 'Analysis service temporarily unavailable' }); } }); router.post('/batch', async (req, res) => { const { props } = req.body; if (!Array.isArray(props) || props.length === 0) { return res.status(400).json({ error: 'props array is required and must not be empty' }); } const results = []; for (const prop of props) { const errors = validateProp(prop); if (errors.length > 0) { results.push({ error: errors.join('; '), input: prop }); continue; } try { const result = await cachedAnalyze(prop); results.push(applyTierGating(result, req.user?.tier || 'free')); } catch (err) { results.push({ error: err.response?.status === 404 ? `Player not found: ${prop.player}` : 'Analysis failed for this prop', input: prop, }); } } return res.json({ results }); }); module.exports = router;