129 lines
4.8 KiB
JavaScript
129 lines
4.8 KiB
JavaScript
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;
|