feat: Feature 2.1 — Parlay Scan with correlation detection + monetization

POST /api/scan/parlay — authenticated parlay analysis:
- Supabase JWT auth middleware (auth.getUser verification)
- 5 correlation types detected between legs (same_game, same_team,
  same_player_conflicting, positive_correlation, blowout_cascade)
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month, atomic scan count increment
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 block with upgrade pitch
- Pitch personalization from scan history (top stats, grades, tier rec)
- DB writes: picks + scan_sessions per scan

30 new tests, 158 total (131 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-21 12:45:15 -04:00
parent c8c0962e56
commit 411cb6f196
14 changed files with 1539 additions and 48 deletions
+66
View File
@@ -0,0 +1,66 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { scanParlay } = require('../services/parlayScanService');
const router = express.Router();
const VALID_STAT_TYPES = new Set([
'points', 'rebounds', 'assists', 'threes', 'blocks',
'steals', 'pra', 'turnovers',
]);
const VALID_DIRECTIONS = new Set(['over', 'under']);
function validateLegs(legs) {
if (!Array.isArray(legs) || legs.length < 2) {
return 'legs array is required with at least 2 props';
}
if (legs.length > 12) {
return 'max_legs_exceeded';
}
for (let i = 0; i < legs.length; i++) {
const leg = legs[i];
if (!leg.player) return `leg ${i}: player is required`;
if (!leg.stat_type) return `leg ${i}: stat_type is required`;
if (leg.stat_type && !VALID_STAT_TYPES.has(leg.stat_type)) {
return `leg ${i}: Invalid stat_type: ${leg.stat_type}`;
}
if (leg.line == null) return `leg ${i}: line is required`;
if (!leg.direction) return `leg ${i}: direction is required`;
if (leg.direction && !VALID_DIRECTIONS.has(leg.direction)) {
return `leg ${i}: Invalid direction: ${leg.direction}`;
}
}
return null;
}
router.post('/parlay', requireAuth, async (req, res) => {
const { legs } = req.body;
const validationError = validateLegs(legs);
if (validationError === 'max_legs_exceeded') {
return res.status(422).json({ error: 'Maximum 12 legs per parlay' });
}
if (validationError) {
return res.status(400).json({ error: validationError });
}
try {
const result = await scanParlay(req.user, legs);
if (result.blocked) {
return res.status(403).json({
error: 'scan_limit_reached',
scan_count: result.scan_count,
scans_remaining: result.scans_remaining,
upgrade_pitch: result.upgrade_pitch,
});
}
return res.json(result);
} catch (err) {
console.error('[BetonBLK] Scan error:', err.message);
return res.status(503).json({ error: 'Scan service temporarily unavailable' });
}
});
module.exports = router;