Session 29: Content generation templates — slate threads, POTD, recaps, matchup previews (1660 tests)

This commit is contained in:
Kev
2026-06-13 21:30:57 -04:00
parent c48aecd510
commit 927c4a5c65
11 changed files with 1063 additions and 1 deletions
+56
View File
@@ -4,6 +4,62 @@
2026-06-12
## Current Phase
SHIP BUILD v29.0 — Content generation templates: structured social/newsletter content from live data (Session 29)
## Session 29 (2026-06-13) — SHIPPED
The data engine that produces raw material for daily social content. Each
template consumes live VYNDR data and returns STRUCTURED OBJECTS (not text,
not images) that degrade gracefully by data level. A formatter renders
plain text; the image/design layer comes later.
Backend 1623 → **1660 tests** (+37), 133 suites, zero regressions. Web
build clean.
### PHASE 1-3 — Template engine + slate thread + POTD
- `contentTemplateService.js`:
- `collectSlateData(sport, deps?)` — gathers schedule + game lines +
grades + streaks + movers + best lines via Promise.allSettled,
INJECTABLE collectors (default wires the real services). Sets
`dataLevel`: full / lines / schedule / empty.
- `generateSlateThread` — hook + content posts + CTA. Full → top-5
graded picks; lines → game-line highlights (best ML, consensus
total/spread, book disagreement) + movers; schedule → game list.
- `generatePOTD` — best grade (full) or game-of-the-day (lines) or
`{ available: false }`.
- Field-alias normalizers so grades from any shape (player/player_name,
side/direction, edge/edge_pct) work.
### PHASE 4-5 — Recap + matchup preview
- `generateResultsRecap(sport, resolvedGrades)` — record, win rate, top
hits, biggest miss, by-tier (A/B/C), Brier score + avg CLV. Pure.
- `generateMatchupPreview(game, gameLines, streaks)` — teams, lines
summary (consensus spread/total, home-favorite), streaks matched to the
two teams, one-line narrative. Degrades to `lines: null`.
### PHASE 6 — Content API
- `GET /api/content/{slate,potd,recap,preview}/:sport` (preview takes
`/:gameId`). `?format=text` adds post-ready strings. Mounted in app.js;
Next proxy `api/content/[...path]/route.ts`.
### PHASE 7 — Formatter
- `contentFormatter.js` — slate thread → array of plain-text posts (one
per role), POTD + recap text blocks. Defensive: never emits "undefined".
### Files created
- `src/services/contentTemplateService.js`, `src/services/contentFormatter.js`
- `src/routes/content.js`
- `web/src/app/api/content/[...path]/route.ts`
- `tests/unit/contentTemplateService.test.js` (22),
`tests/unit/contentFormatter.test.js` (7),
`tests/integration/contentRoutes.test.js` (8)
### Files modified
- `src/app.js` (mount /api/content)
---
## Previous Phase
SHIP BUILD v28.0 — Parlay builder, line-movement tracking, book comparison (Session 28)
## Session 28 (2026-06-13) — SHIPPED
+14
View File
@@ -780,3 +780,17 @@
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T15:56:53.881Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T15:56:53.940Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-13T20:27:36.907Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T20:27:36.973Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T20:27:37.223Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T20:27:38.042Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T20:27:38.259Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-13T20:44:17.269Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T20:44:18.291Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-13T20:44:18.386Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-13T20:44:19.184Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-13T20:44:19.233Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
+4
View File
@@ -159,6 +159,10 @@ const lineMovementRoutes = require('./routes/lineMovement');
app.use('/api/lines', lineMovementRoutes);
const bookComparisonRoutes = require('./routes/bookComparison');
app.use('/api/books', bookComparisonRoutes);
// Session 29 — content templates: structured social/newsletter content
// generated from live data, degrading gracefully by data level.
const contentRoutes = require('./routes/content');
app.use('/api/content', contentRoutes);
// Session 18 — internal ops endpoints (admin dashboard triggers,
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
// the public surface; the Next.js admin route proxies through with
+102
View File
@@ -0,0 +1,102 @@
'use strict';
/**
* /api/content (Session 29)
*
* Structured content objects generated from live VYNDR data, degrading
* gracefully by data level. Read-only, zero-credit (reads cached data).
* `?format=text` additionally returns post-ready plain text.
*
* GET /api/content/slate/:sport → daily slate thread
* GET /api/content/potd/:sport → prop (or game) of the day
* GET /api/content/recap/:sport → results recap (needs resolved grades)
* GET /api/content/preview/:sport/:gameId → matchup preview for one game
*/
const express = require('express');
const template = require('../services/contentTemplateService');
const formatter = require('../services/contentFormatter');
const { cacheGet } = require('../utils/redis');
const router = express.Router();
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Every post is a free ad' };
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
function guard(req, res) {
const sport = String(req.params.sport || '').toLowerCase();
if (!SUPPORTED.has(sport)) {
res.status(404).set(MISSION_HEADER).json({ error: `No content for sport: ${sport}` });
return null;
}
return sport;
}
router.get('/slate/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
const data = await template.collectSlateData(sport);
const thread = template.generateSlateThread(sport, data);
const body = { ...thread };
if (req.query.format === 'text') body.text = formatter.formatSlateThread(thread);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/slate/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/potd/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
const data = await template.collectSlateData(sport);
const potd = template.generatePOTD(sport, data);
const body = { ...potd };
if (req.query.format === 'text') body.text = formatter.formatPOTD(potd);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/potd/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/recap/:sport', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
try {
// Resolved grades land in a daily cache when the grading flow settles.
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`results:${sport}:${utc}`)) ?? (await cacheGet(`results:${sport}`));
const resolved = Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : [];
const recap = template.generateResultsRecap(sport, resolved);
const body = { ...recap };
if (req.query.format === 'text') body.text = formatter.formatRecap(recap);
return res.set(MISSION_HEADER).json(body);
} catch (err) {
console.error(`[content/recap/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
router.get('/preview/:sport/:gameId', async (req, res) => {
const sport = guard(req, res);
if (!sport) return undefined;
const { gameId } = req.params;
try {
const data = await template.collectSlateData(sport);
const game = (data.schedule || []).find((g) => String(g.id) === String(gameId));
if (!game) {
return res.status(404).set(MISSION_HEADER).json({ error: 'Game not on today\'s schedule.' });
}
const lines = template.__internals.findGameLinesFor(game, data.gameLines);
const preview = template.generateMatchupPreview(game, lines, data.streaks);
return res.set(MISSION_HEADER).json(preview);
} catch (err) {
console.error(`[content/preview/${sport}]`, err.message);
return res.status(500).set(MISSION_HEADER).json({ error: 'Content generation failed' });
}
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
/**
* Content formatter (Session 29).
*
* Transforms structured content objects (from contentTemplateService) into
* platform-ready PLAIN TEXT — suitable for X threads / Telegram. Image
* formatting is a separate design-layer concern. Pure + defensive: a
* missing field renders as a sensible blank, never "undefined".
*/
function fmtOdds(o) {
const n = Number(o);
if (!Number.isFinite(n)) return String(o ?? '');
return n > 0 ? `+${n}` : `${n}`;
}
function fmtTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
} catch {
return String(iso);
}
}
function formatPickPost(p) {
const lines = [`${p.player} · ${p.stat} ${p.side || ''} ${p.line ?? ''}`.replace(/\s+/g, ' ').trim()];
lines.push(`Grade: ${p.grade}${p.confidence ? ` · ${p.confidence}% confidence` : ''}`);
if (p.edge) lines.push(`Edge: ${p.edge > 0 ? '+' : ''}${p.edge}%`);
if (p.analysis) lines.push(p.analysis);
return lines.join('\n');
}
function formatGameHighlight(p) {
const parts = [`${p.game}${p.time ? ` · ${fmtTime(p.time)}` : ''}`];
if (p.bestAwayML) parts.push(`Best away ML: ${fmtOdds(p.bestAwayML.odds)} (${p.bestAwayML.book})`);
if (p.bestHomeML) parts.push(`Best home ML: ${fmtOdds(p.bestHomeML.odds)} (${p.bestHomeML.book})`);
if (p.total != null) parts.push(`O/U ${p.total}`);
if (p.bookCount) parts.push(`${p.bookCount} books`);
return parts.join('\n');
}
function formatMovers(p) {
const head = '📈 Biggest line moves:';
const rows = (p.movers || []).map((m) => {
const d = m.delta > 0 ? `+${m.delta}` : `${m.delta}`;
return `${m.player} ${String(m.stat || '').replace(/_/g, ' ')}: ${m.opening ?? '?'}${m.current ?? '?'} (${d})${m.sharpSignal ? ' ⚡' : ''}`;
});
return [head, ...rows].join('\n');
}
function formatSchedule(p) {
return (p.games || [])
.map((g) => `${g.away || '?'} @ ${g.home || '?'}${g.time ? ` · ${fmtTime(g.time)}` : ''}`)
.join('\n');
}
function formatPost(post) {
switch (post.role) {
case 'hook': return post.text || '';
case 'cta': return post.text || '';
case 'pick': return formatPickPost(post);
case 'game_highlight': return formatGameHighlight(post);
case 'movers': return formatMovers(post);
case 'schedule': return formatSchedule(post);
default: return '';
}
}
/** Returns an array of post-ready strings (one per thread post). */
function formatSlateThread(thread) {
if (!thread || !Array.isArray(thread.posts)) return [];
return thread.posts.map(formatPost);
}
/** Single-block POTD text. */
function formatPOTD(potd) {
if (!potd || potd.available === false) return 'No standout pick today — check the full slate at vyndr.app.';
if (potd.dataLevel === 'lines') {
return `🎯 GAME OF THE DAY\n${potd.game}${potd.time ? ` · ${fmtTime(potd.time)}` : ''}\n${potd.total != null ? `O/U ${potd.total}` : ''}${potd.bestHomeML ? `\nBest home ML ${fmtOdds(potd.bestHomeML.odds)} (${potd.bestHomeML.book})` : ''}`.trim();
}
return `🎯 PROP OF THE DAY\n${formatPickPost(potd)}`;
}
/** Recap text block. */
function formatRecap(recap) {
if (!recap || recap.available === false) return 'No graded results in yet today.';
const { record, winRate } = recap;
const lines = [`📊 VYNDR RECAP — ${recap.date}`];
lines.push(`Record: ${record.wins}-${record.losses}${record.pushes ? `-${record.pushes}` : ''}${winRate != null ? ` (${Math.round(winRate * 100)}%)` : ''}`);
if (recap.topHits && recap.topHits.length) {
lines.push('Top hits:');
for (const h of recap.topHits) lines.push(`${h.player} ${h.stat} ${h.side || ''} ${h.line ?? ''}`.replace(/\s+/g, ' ').trim());
}
if (recap.metrics && recap.metrics.brierScore != null) lines.push(`Brier: ${recap.metrics.brierScore}`);
return lines.join('\n');
}
module.exports = {
formatSlateThread,
formatPOTD,
formatRecap,
__internals: { formatPost, formatPickPost, formatGameHighlight, formatMovers, formatSchedule },
};
+421
View File
@@ -0,0 +1,421 @@
'use strict';
/**
* Content template engine (Session 29).
*
* Generates STRUCTURED DATA objects (not text, not images) from live VYNDR
* data. A separate formatter (contentFormatter) renders text; a future
* design layer renders images.
*
* Templates degrade gracefully by data level:
* full → graded props exist → rich content (grade, edge, projection)
* lines → Tank01 game lines exist → line-focused content
* schedule → only ESPN schedule → matchup/time content
* empty → nothing today → minimal, never a crash
*
* The pure generators take a `data` bundle and never touch the network.
* `collectSlateData` is the thin orchestrator that gathers the bundle from
* the live services; its collectors are injectable so it stays testable.
*/
const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, 'D+': 9, D: 10, 'D-': 11, F: 12 };
const SITE = 'vyndr.app';
// ---- field-alias normalizers (grades come from several shapes) --------
function gradeField(g, ...keys) {
for (const k of keys) if (g && g[k] != null && g[k] !== '') return g[k];
return null;
}
function normalizeGrade(g) {
return {
player: gradeField(g, 'player_name', 'player'),
stat: gradeField(g, 'stat_type', 'stat'),
line: gradeField(g, 'line'),
side: gradeField(g, 'side', 'direction'),
grade: gradeField(g, 'grade') || 'C',
confidence: Number(gradeField(g, 'confidence')) || 0,
edge: Number(gradeField(g, 'edge', 'edge_pct')) || 0,
projection: gradeField(g, 'projection'),
matchup: gradeField(g, 'matchup'),
killConditions: g?.killConditions || g?.kill_conditions_triggered || [],
analysis: gradeField(g, 'analysis', 'one_line_reason') || (g?.reasoning && g.reasoning.summary) || null,
_raw: g,
};
}
// ---- data level -------------------------------------------------------
function determineDataLevel(data) {
const grades = data?.grades || [];
const gameLines = data?.gameLines || {};
const schedule = data?.schedule || [];
if (grades.length > 0) return 'full';
if (gameLines && Object.keys(gameLines).length > 0) return 'lines';
if (schedule.length > 0) return 'schedule';
return 'empty';
}
function prettyDate(now = Date.now()) {
return new Date(now).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
}
function sportLabel(sport) {
return String(sport || '').toUpperCase();
}
// ---- game-line highlight extraction -----------------------------------
function numOdds(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function americanToDecimal(odds) {
const n = Number(odds);
if (!Number.isFinite(n) || n === 0) return null;
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
}
// books: array of [bookName, { homeML, awayML, total, homeSpread, ... }]
function bestOdds(books, field) {
let best = null;
for (const [book, line] of books) {
const dec = americanToDecimal(line?.[field]);
if (dec == null) continue;
if (!best || dec > best.decimal) best = { book, odds: line[field], decimal: dec };
}
return best;
}
function consensus(books, field) {
const vals = books.map(([, l]) => Number(l?.[field])).filter((n) => Number.isFinite(n));
if (vals.length === 0) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10;
}
// Disagreement = spread between best & worst home-ML decimal payout.
function bookDisagreement(books) {
const decs = books.map(([, l]) => americanToDecimal(l?.homeML)).filter((n) => n != null);
if (decs.length < 2) return 0;
return Math.round((Math.max(...decs) - Math.min(...decs)) * 100) / 100;
}
function findGameLinesFor(game, gameLines) {
if (!gameLines) return null;
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
for (const entry of Object.values(gameLines)) {
const eh = String(entry.homeTeam || '').toUpperCase();
const ea = String(entry.awayTeam || '').toUpperCase();
if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
}
return null;
}
function extractGameLineHighlights(schedule, gameLines) {
const highlights = [];
for (const game of schedule || []) {
const lines = findGameLinesFor(game, gameLines);
const books = lines && lines.books ? Object.entries(lines.books) : [];
if (books.length === 0) continue;
highlights.push({
type: 'game_line_highlight',
game: `${game.awayTeam?.abbreviation || '?'} @ ${game.homeTeam?.abbreviation || '?'}`,
time: game.gameTime || null,
venue: game.venue || null,
bookCount: books.length,
bestHomeML: bestOdds(books, 'homeML'),
bestAwayML: bestOdds(books, 'awayML'),
total: consensus(books, 'total'),
spread: consensus(books, 'homeSpread'),
disagreement: bookDisagreement(books),
});
}
return highlights.sort((a, b) => (b.disagreement || 0) - (a.disagreement || 0));
}
// ---- hook + CTA -------------------------------------------------------
function generateHookText(sport, data) {
const label = sportLabel(sport);
const date = prettyDate();
const games = (data.schedule || []).length;
const bookCount = maxBookCount(data.gameLines);
if (data.dataLevel === 'full') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games graded${bookCount ? ` · lines from ${bookCount} books` : ''}. Top reads below. 🧵`;
}
if (data.dataLevel === 'lines') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games · lines from ${bookCount} sportsbooks. Here's what stands out. 🧵`;
}
if (data.dataLevel === 'schedule') {
return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games on the board today. 🧵`;
}
return `VYNDR — ${date}. No ${label} games today. Back tomorrow.`;
}
function maxBookCount(gameLines) {
if (!gameLines) return 0;
let max = 0;
for (const entry of Object.values(gameLines)) {
const n = entry && entry.books ? Object.keys(entry.books).length : 0;
if (n > max) max = n;
}
return max;
}
function generateCTAText(dataLevel) {
if (dataLevel === 'empty') return `Full slate + intelligence at ${SITE}.`;
return `Full slate live at ${SITE}. Free tier: reads on us. Founder pricing from $14.99/mo.`;
}
function generateAnalysisLine(grade) {
if (grade.analysis) return grade.analysis;
const edge = grade.edge ? `${grade.edge > 0 ? '+' : ''}${grade.edge}% edge` : null;
const proj = grade.projection != null ? `projection ${grade.projection}` : null;
return [proj, edge].filter(Boolean).join(' · ') || 'Model edge on this line.';
}
// ---- slate thread -----------------------------------------------------
function generateSlateThread(sport, data) {
const dataLevel = data.dataLevel || determineDataLevel(data);
const enriched = { ...data, dataLevel };
const thread = { type: 'slate_thread', sport, date: prettyDate(), dataLevel, posts: [] };
thread.posts.push({ role: 'hook', text: generateHookText(sport, enriched) });
if (dataLevel === 'full') {
const top = (data.grades || []).map(normalizeGrade)
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5);
for (const g of top) {
thread.posts.push({
role: 'pick',
player: g.player, stat: g.stat, line: g.line, side: g.side,
grade: g.grade, confidence: g.confidence, edge: g.edge, projection: g.projection,
analysis: generateAnalysisLine(g), cardData: g._raw,
});
}
} else if (dataLevel === 'lines') {
const highlights = extractGameLineHighlights(data.schedule, data.gameLines);
for (const h of highlights.slice(0, 4)) thread.posts.push({ role: 'game_highlight', ...h });
if ((data.movers || []).length > 0) {
thread.posts.push({ role: 'movers', movers: data.movers.slice(0, 3) });
}
} else if (dataLevel === 'schedule') {
const games = (data.schedule || []).slice(0, 8).map((g) => ({
away: g.awayTeam?.name || g.awayTeam?.abbreviation || null,
home: g.homeTeam?.name || g.homeTeam?.abbreviation || null,
time: g.gameTime || null,
venue: g.venue || null,
}));
thread.posts.push({ role: 'schedule', games });
}
thread.posts.push({ role: 'cta', text: generateCTAText(dataLevel) });
return thread;
}
// ---- prop / game of the day -------------------------------------------
function generatePOTD(sport, data) {
const dataLevel = data.dataLevel || determineDataLevel(data);
if (dataLevel === 'full' && (data.grades || []).length > 0) {
const best = (data.grades || []).map(normalizeGrade).reduce((a, b) => (b.confidence > a.confidence ? b : a));
return {
type: 'potd', dataLevel: 'full',
player: best.player, stat: best.stat, line: best.line, side: best.side,
grade: best.grade, confidence: best.confidence, edge: best.edge,
projection: best.projection, matchup: best.matchup,
killConditions: best.killConditions,
analysis: generateAnalysisLine(best), cardData: best._raw,
};
}
if (dataLevel === 'lines') {
const top = extractGameLineHighlights(data.schedule, data.gameLines)[0];
if (top) return { type: 'potd', dataLevel: 'lines', subtype: 'game_of_the_day', ...top };
}
return { type: 'potd', dataLevel: 'empty', available: false };
}
// ---- results recap (pure, takes resolved grades) ----------------------
function gradeTier(grade) {
return String(grade || '').charAt(0).toUpperCase();
}
function tierRecord(grades, tier) {
const inTier = grades.filter((g) => gradeTier(gradeField(g, 'grade')) === tier);
const wins = inTier.filter((g) => g.result === 'win').length;
const losses = inTier.filter((g) => g.result === 'loss').length;
return { wins, losses, total: inTier.length };
}
function brierScore(grades) {
// Brier = mean( (p - outcome)^2 ); p from confidence%, outcome 1 win / 0 loss.
const scored = grades.filter((g) => g.result === 'win' || g.result === 'loss');
if (scored.length === 0) return null;
const sum = scored.reduce((acc, g) => {
const p = (Number(gradeField(g, 'confidence')) || 50) / 100;
const outcome = g.result === 'win' ? 1 : 0;
return acc + (p - outcome) ** 2;
}, 0);
return Math.round((sum / scored.length) * 1000) / 1000;
}
function avgCLV(grades) {
const vals = grades.map((g) => Number(gradeField(g, 'clv'))).filter((n) => Number.isFinite(n));
if (vals.length === 0) return null;
return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) / 100;
}
function generateResultsRecap(sport, resolvedGrades) {
if (!Array.isArray(resolvedGrades) || resolvedGrades.length === 0) {
return { type: 'recap', available: false };
}
const wins = resolvedGrades.filter((g) => g.result === 'win');
const losses = resolvedGrades.filter((g) => g.result === 'loss');
const pushes = resolvedGrades.filter((g) => g.result === 'push');
const decided = wins.length + losses.length;
const byConfidence = (arr) => [...arr].sort((a, b) => (Number(gradeField(b, 'confidence')) || 0) - (Number(gradeField(a, 'confidence')) || 0));
return {
type: 'recap',
available: true,
sport,
date: prettyDate(),
record: { wins: wins.length, losses: losses.length, pushes: pushes.length },
winRate: decided > 0 ? Math.round((wins.length / decided) * 1000) / 1000 : null,
topHits: byConfidence(wins).slice(0, 3).map((g) => ({
player: gradeField(g, 'player_name', 'player'),
stat: gradeField(g, 'stat_type', 'stat'),
line: gradeField(g, 'line'),
side: gradeField(g, 'side', 'direction'),
grade: gradeField(g, 'grade'),
actual: gradeField(g, 'actual'),
})),
biggestMiss: losses.length > 0 ? (() => {
const m = byConfidence(losses)[0];
return {
player: gradeField(m, 'player_name', 'player'),
stat: gradeField(m, 'stat_type', 'stat'),
line: gradeField(m, 'line'),
side: gradeField(m, 'side', 'direction'),
grade: gradeField(m, 'grade'),
actual: gradeField(m, 'actual'),
};
})() : null,
byTier: { A: tierRecord(resolvedGrades, 'A'), B: tierRecord(resolvedGrades, 'B'), C: tierRecord(resolvedGrades, 'C') },
metrics: { brierScore: brierScore(resolvedGrades), clv: avgCLV(resolvedGrades) },
};
}
// ---- matchup preview --------------------------------------------------
function generateMatchupNarrative(game, lines, streaks) {
const away = game.awayTeam?.name || game.awayTeam?.abbreviation || 'Away';
const home = game.homeTeam?.name || game.homeTeam?.abbreviation || 'Home';
const matched = (streaks || []).length;
if (lines && lines.total != null) {
return `${away} at ${home} — consensus total ${lines.total}${matched ? `, ${matched} active player streak${matched === 1 ? '' : 's'} in play` : ''}.`;
}
return `${away} at ${home}${matched ? `${matched} player${matched === 1 ? '' : 's'} riding a streak` : ''}.`;
}
function generateMatchupPreview(game, gameLines, streaks) {
const books = gameLines && gameLines.books ? Object.entries(gameLines.books) : [];
const lines = books.length > 0 ? {
spread: consensus(books, 'homeSpread'),
total: consensus(books, 'total'),
homeFavorite: (() => {
const hs = consensus(books, 'homeSpread');
return hs != null ? hs < 0 : null;
})(),
bookCount: books.length,
} : null;
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
const playerStreaks = (streaks || []).filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
});
return {
type: 'matchup_preview',
away: game.awayTeam || null,
home: game.homeTeam || null,
time: game.gameTime || null,
venue: game.venue || null,
lines,
playerStreaks,
narrative: generateMatchupNarrative(game, lines, playerStreaks),
};
}
// ---- orchestrator (injectable collectors for testability) -------------
function defaultCollectors() {
const scheduleService = require('./scheduleService');
const lineSnapshotService = require('./lineSnapshotService');
const bookComparisonService = require('./bookComparisonService');
const streaksService = require('./streaksService');
const { loadRosterLogs } = require('./rosterLogs');
const { cacheGet } = require('../utils/redis');
const nbaAdapter = require('./adapters/tank01NbaAdapter');
const mlbAdapter = require('./adapters/tank01MlbAdapter');
const { __internals: glInternals } = require('../routes/gameLines');
return {
getSchedule: (sport, date) => scheduleService.getSchedule(sport, date),
getGameLines: async (sport, date) => {
const fetcher = sport === 'nba' ? nbaAdapter.getNBABettingOdds : sport === 'mlb' ? mlbAdapter.getMLBBettingOdds : null;
if (!fetcher) return {};
const body = await fetcher(date).catch(() => null);
return body ? glInternals.normalizeGameLines(body) : {};
},
getGrades: async (sport) => {
// odds-api grades cache (when present); degrade to [] otherwise.
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`grades:${sport}:${utc}`)) ?? (await cacheGet(`grades:${sport}`));
return Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : [];
},
getStreaks: async (sport) => {
const roster = await loadRosterLogs(sport);
return streaksService.computeStreaks(roster, sport, {});
},
getMovers: (sport) => lineSnapshotService.getBiggestMovers(sport, { limit: 10 }),
getBestLines: async (sport) => {
const utc = new Date().toISOString().split('T')[0];
const cache = (await cacheGet(`odds:${sport}:${utc}`)) ?? (await cacheGet(`odds:${sport}`));
const props = Array.isArray(cache?.props) ? cache.props : [];
return bookComparisonService.bestLines(props, { limit: 10 });
},
};
}
async function collectSlateData(sport, deps) {
const c = deps || defaultCollectors();
const date = (() => {
try { return require('./scheduleService').todayET(); } catch { return undefined; }
})();
const [schedule, gameLines, grades, streaks, movers, bestLines] = await Promise.allSettled([
c.getSchedule(sport, date),
c.getGameLines(sport, date),
c.getGrades(sport),
c.getStreaks(sport),
c.getMovers(sport),
c.getBestLines(sport),
]);
const val = (r, fallback) => (r.status === 'fulfilled' && r.value != null ? r.value : fallback);
const data = {
sport,
schedule: val(schedule, []),
gameLines: val(gameLines, {}),
grades: val(grades, []),
streaks: val(streaks, []),
movers: val(movers, []),
bestLines: val(bestLines, []),
};
data.dataLevel = determineDataLevel(data);
return data;
}
module.exports = {
collectSlateData,
determineDataLevel,
generateSlateThread,
generatePOTD,
generateResultsRecap,
generateMatchupPreview,
__internals: {
normalizeGrade, extractGameLineHighlights, generateHookText, generateCTAText,
brierScore, avgCLV, tierRecord, bestOdds, consensus, bookDisagreement, findGameLinesFor,
},
};
+110
View File
@@ -0,0 +1,110 @@
// Integration: /api/content routes (Session 29).
// The template service is mocked at the collector boundary via redis +
// real generators; recap reads a mocked results cache.
const express = require('express');
const request = require('supertest');
const mockStore = {};
jest.mock('../../src/utils/redis', () => ({
cacheGet: jest.fn(async (k) => (k in mockStore ? mockStore[k] : null)),
getRedisClient: () => ({ scan: async () => ['0', []], lrange: async () => [] }),
isDegraded: () => false,
}));
// Mock collectSlateData so the route doesn't hit live adapters; keep the
// real generators so we exercise the actual content shapes.
jest.mock('../../src/services/contentTemplateService', () => {
const actual = jest.requireActual('../../src/services/contentTemplateService');
return { ...actual, collectSlateData: jest.fn() };
});
const template = require('../../src/services/contentTemplateService');
function mountApp() {
delete require.cache[require.resolve('../../src/routes/content')];
const app = express();
app.use(express.json());
app.use('/api/content', require('../../src/routes/content'));
return app;
}
const SLATE = {
sport: 'mlb',
schedule: [{ id: 'g1', homeTeam: { name: 'Reds', abbreviation: 'CIN' }, awayTeam: { name: 'D-backs', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' }],
gameLines: { '20260613_ARI@CIN': { homeTeam: 'CIN', awayTeam: 'ARI', books: { bet365: { homeML: '-120', awayML: '+100', total: '9.5', homeSpread: '-1.5' } } } },
grades: [],
streaks: [{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 }],
movers: [],
bestLines: [],
dataLevel: 'lines',
};
beforeEach(() => {
for (const k of Object.keys(mockStore)) delete mockStore[k];
jest.clearAllMocks();
template.collectSlateData.mockResolvedValue(SLATE);
});
describe('GET /api/content/slate/:sport', () => {
test('returns a slate thread structure', async () => {
const res = await request(mountApp()).get('/api/content/slate/mlb');
expect(res.status).toBe(200);
expect(res.body.type).toBe('slate_thread');
expect(res.body.dataLevel).toBe('lines');
expect(res.body.posts[0].role).toBe('hook');
});
test('?format=text adds formatted post strings', async () => {
const res = await request(mountApp()).get('/api/content/slate/mlb?format=text');
expect(Array.isArray(res.body.text)).toBe(true);
expect(res.body.text[0]).toMatch(/VYNDR SLATE/);
});
test('unsupported sport → 404', async () => {
const res = await request(mountApp()).get('/api/content/slate/cricket');
expect(res.status).toBe(404);
});
});
describe('GET /api/content/potd/:sport', () => {
test('lines data → game of the day', async () => {
const res = await request(mountApp()).get('/api/content/potd/mlb');
expect(res.status).toBe(200);
expect(res.body.subtype).toBe('game_of_the_day');
});
});
describe('GET /api/content/recap/:sport', () => {
test('no resolved grades → available:false', async () => {
const res = await request(mountApp()).get('/api/content/recap/mlb');
expect(res.status).toBe(200);
expect(res.body.available).toBe(false);
});
test('with cached results → record computed', async () => {
const utc = new Date().toISOString().split('T')[0];
mockStore[`results:mlb:${utc}`] = [
{ player: 'A', grade: 'A', confidence: 80, result: 'win' },
{ player: 'B', grade: 'B', confidence: 60, result: 'loss' },
];
const res = await request(mountApp()).get('/api/content/recap/mlb');
expect(res.body.available).toBe(true);
expect(res.body.record).toEqual({ wins: 1, losses: 1, pushes: 0 });
});
});
describe('GET /api/content/preview/:sport/:gameId', () => {
test('returns matchup data for a scheduled game', async () => {
const res = await request(mountApp()).get('/api/content/preview/mlb/g1');
expect(res.status).toBe(200);
expect(res.body.type).toBe('matchup_preview');
expect(res.body.home.abbreviation).toBe('CIN');
expect(res.body.lines.bookCount).toBe(1);
expect(res.body.playerStreaks.map((s) => s.player)).toEqual(['Acuna']);
});
test('unknown game → 404', async () => {
const res = await request(mountApp()).get('/api/content/preview/mlb/nope');
expect(res.status).toBe(404);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Unit: content formatter (Session 29).
const fmt = require('../../src/services/contentFormatter');
describe('contentFormatter — slate thread', () => {
test('formats each post role into readable text', () => {
const thread = {
posts: [
{ role: 'hook', text: '🔥 VYNDR SLATE' },
{ role: 'pick', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78, edge: 6.2, analysis: 'Edge on the line.' },
{ role: 'game_highlight', game: 'ARI @ CIN', time: null, bestHomeML: { odds: '-120', book: 'betmgm' }, total: 9.5, bookCount: 2 },
{ role: 'movers', movers: [{ player: 'X', stat: 'hits', opening: 1.5, current: 2.5, delta: 1, sharpSignal: false }] },
{ role: 'schedule', games: [{ away: 'ATL', home: 'NYM', time: null }] },
{ role: 'cta', text: 'Full slate at vyndr.app' },
],
};
const out = fmt.formatSlateThread(thread);
expect(out[0]).toBe('🔥 VYNDR SLATE');
expect(out[1]).toContain('Wemby · points over 28.5');
expect(out[1]).toContain('Grade: A · 78% confidence');
expect(out[1]).toContain('Edge: +6.2%');
expect(out[2]).toContain('ARI @ CIN');
expect(out[2]).toContain('betmgm');
expect(out[3]).toContain('Biggest line moves');
expect(out[4]).toContain('ATL @ NYM');
expect(out[5]).toBe('Full slate at vyndr.app');
});
test('never emits "undefined"', () => {
const out = fmt.formatSlateThread({ posts: [{ role: 'pick', player: 'X', stat: 'pts', grade: 'B' }] });
expect(out[0]).not.toMatch(/undefined/);
});
test('non-thread input → empty array', () => {
expect(fmt.formatSlateThread(null)).toEqual([]);
});
});
describe('contentFormatter — POTD + recap', () => {
test('POTD full', () => {
const t = fmt.formatPOTD({ dataLevel: 'full', player: 'Wemby', stat: 'points', side: 'over', line: 28.5, grade: 'A', confidence: 78 });
expect(t).toContain('PROP OF THE DAY');
expect(t).toContain('Wemby');
});
test('POTD game of the day', () => {
const t = fmt.formatPOTD({ dataLevel: 'lines', subtype: 'game_of_the_day', game: 'ARI @ CIN', total: 9.5 });
expect(t).toContain('GAME OF THE DAY');
expect(t).toContain('O/U 9.5');
});
test('POTD unavailable', () => {
expect(fmt.formatPOTD({ available: false })).toMatch(/vyndr\.app/);
});
test('recap formats record + win rate', () => {
const t = fmt.formatRecap({ available: true, date: 'Friday', record: { wins: 3, losses: 2, pushes: 1 }, winRate: 0.6, topHits: [{ player: 'A', stat: 'points', side: 'over', line: 20 }], metrics: { brierScore: 0.18 } });
expect(t).toContain('3-2-1 (60%)');
expect(t).toContain('✅ A points over 20');
expect(t).toContain('Brier: 0.18');
});
});
+166
View File
@@ -0,0 +1,166 @@
// Unit: content template engine (Session 29). Pure generators.
const svc = require('../../src/services/contentTemplateService');
// ---- fixtures ----
const schedule = [
{ id: '1', homeTeam: { name: 'Cincinnati Reds', abbreviation: 'CIN' }, awayTeam: { name: 'Arizona Diamondbacks', abbreviation: 'ARI' }, gameTime: '2026-06-13T23:10:00Z', venue: 'GABP' },
{ id: '2', homeTeam: { name: 'New York Mets', abbreviation: 'NYM' }, awayTeam: { name: 'Atlanta Braves', abbreviation: 'ATL' }, gameTime: '2026-06-13T23:40:00Z', venue: 'Citi Field' },
];
const gameLines = {
'20260613_ARI@CIN': {
homeTeam: 'CIN', awayTeam: 'ARI',
books: {
bet365: { homeML: '-130', awayML: '+110', total: '9.5', homeSpread: '-1.5' },
betmgm: { homeML: '-120', awayML: '+100', total: '9', homeSpread: '-1.5' },
},
},
};
const grades = [
{ player: 'Wembanyama', stat: 'points', line: 28.5, side: 'over', grade: 'A', confidence: 78, edge: 6.2, projection: 31.1 },
{ player_name: 'Brunson', stat_type: 'assists', line: 7.5, direction: 'under', grade: 'B+', confidence: 64, edge_pct: 3.1, projection: 6.8 },
];
describe('determineDataLevel', () => {
test('full when grades exist', () => {
expect(svc.determineDataLevel({ grades, gameLines, schedule })).toBe('full');
});
test('lines when only game lines', () => {
expect(svc.determineDataLevel({ grades: [], gameLines, schedule })).toBe('lines');
});
test('schedule when only schedule', () => {
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule })).toBe('schedule');
});
test('empty when nothing', () => {
expect(svc.determineDataLevel({ grades: [], gameLines: {}, schedule: [] })).toBe('empty');
});
});
describe('generateSlateThread', () => {
test('full data → hook + up-to-5 picks + CTA', () => {
const t = svc.generateSlateThread('nba', { schedule, gameLines, grades, dataLevel: 'full' });
expect(t.posts[0].role).toBe('hook');
expect(t.posts[t.posts.length - 1].role).toBe('cta');
const picks = t.posts.filter((p) => p.role === 'pick');
expect(picks).toHaveLength(2);
// Sorted by confidence desc — Wemby (78) first.
expect(picks[0].player).toBe('Wembanyama');
expect(picks[1].player).toBe('Brunson'); // normalized from player_name
expect(picks[1].side).toBe('under'); // normalized from direction
});
test('lines-only → game highlights (+ movers when present)', () => {
const t = svc.generateSlateThread('mlb', { schedule, gameLines, grades: [], movers: [{ player: 'X', delta: 2 }] });
expect(t.dataLevel).toBe('lines');
const highlights = t.posts.filter((p) => p.role === 'game_highlight');
expect(highlights.length).toBeGreaterThan(0);
expect(highlights[0].bookCount).toBe(2);
expect(t.posts.some((p) => p.role === 'movers')).toBe(true);
});
test('schedule-only → game list', () => {
const t = svc.generateSlateThread('mlb', { schedule, gameLines: {}, grades: [] });
expect(t.dataLevel).toBe('schedule');
const sched = t.posts.find((p) => p.role === 'schedule');
expect(sched.games).toHaveLength(2);
expect(sched.games[0].home).toBe('Cincinnati Reds');
});
test('hook text adapts to data level', () => {
expect(svc.generateSlateThread('mlb', { schedule, grades }).posts[0].text).toMatch(/graded/i);
expect(svc.generateSlateThread('mlb', { schedule, gameLines, grades: [] }).posts[0].text).toMatch(/sportsbooks/i);
});
test('empty schedule → minimal thread, not a crash', () => {
const t = svc.generateSlateThread('mlb', { schedule: [], gameLines: {}, grades: [] });
expect(t.dataLevel).toBe('empty');
expect(t.posts[0].role).toBe('hook');
expect(t.posts.some((p) => p.role === 'cta')).toBe(true);
});
});
describe('generatePOTD', () => {
test('full → best grade selected', () => {
const p = svc.generatePOTD('nba', { grades, dataLevel: 'full' });
expect(p.dataLevel).toBe('full');
expect(p.player).toBe('Wembanyama'); // highest confidence
expect(p.grade).toBe('A');
});
test('lines → game of the day', () => {
const p = svc.generatePOTD('mlb', { grades: [], gameLines, schedule });
expect(p.dataLevel).toBe('lines');
expect(p.subtype).toBe('game_of_the_day');
expect(p.game).toContain('@');
});
test('no data → available:false', () => {
const p = svc.generatePOTD('mlb', { grades: [], gameLines: {}, schedule: [] });
expect(p.available).toBe(false);
});
});
describe('generateResultsRecap', () => {
const resolved = [
{ player: 'A', stat: 'points', grade: 'A', confidence: 80, result: 'win', clv: 1.2 },
{ player: 'B', stat: 'hits', grade: 'A-', confidence: 70, result: 'win', clv: 0.8 },
{ player: 'C', stat: 'reb', grade: 'B', confidence: 60, result: 'win', clv: -0.2 },
{ player: 'D', stat: 'ast', grade: 'A', confidence: 75, result: 'loss', clv: -1.0 },
{ player: 'E', stat: 'ks', grade: 'B', confidence: 55, result: 'loss', clv: 0.1 },
{ player: 'F', stat: 'tb', grade: 'C', confidence: 50, result: 'push' },
];
test('record + win rate', () => {
const r = svc.generateResultsRecap('mlb', resolved);
expect(r.record).toEqual({ wins: 3, losses: 2, pushes: 1 });
expect(r.winRate).toBe(0.6); // 3 / (3+2)
});
test('top hits sorted by confidence', () => {
const r = svc.generateResultsRecap('mlb', resolved);
expect(r.topHits[0].player).toBe('A'); // 80 conf win
expect(r.topHits).toHaveLength(3);
});
test('biggest miss = highest-confidence loss', () => {
const r = svc.generateResultsRecap('mlb', resolved);
expect(r.biggestMiss.player).toBe('D'); // 75 conf loss
});
test('by-tier breakdown', () => {
const r = svc.generateResultsRecap('mlb', resolved);
expect(r.byTier.A).toEqual({ wins: 2, losses: 1, total: 3 });
expect(r.byTier.B).toEqual({ wins: 1, losses: 1, total: 2 });
});
test('brier + clv computed', () => {
const r = svc.generateResultsRecap('mlb', resolved);
expect(typeof r.metrics.brierScore).toBe('number');
expect(typeof r.metrics.clv).toBe('number');
});
test('no resolved grades → available:false', () => {
expect(svc.generateResultsRecap('mlb', []).available).toBe(false);
});
});
describe('generateMatchupPreview', () => {
const streaks = [
{ player: 'Acuna', team: 'ARI', description: '3-game hit streak', currentStreak: 3 },
{ player: 'Soto', team: 'NYM', description: '2-game HR streak', currentStreak: 2 },
];
test('includes teams, time, venue', () => {
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
expect(p.home.abbreviation).toBe('CIN');
expect(p.away.abbreviation).toBe('ARI');
expect(p.venue).toBe('GABP');
});
test('lines summary from game lines', () => {
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
expect(p.lines.bookCount).toBe(2);
expect(p.lines.total).toBe(9.3); // consensus of 9.5 + 9, rounded to 1dp
expect(p.lines.homeFavorite).toBe(true); // homeSpread -1.5
});
test('player streaks matched to the two teams', () => {
const p = svc.generateMatchupPreview(schedule[0], gameLines['20260613_ARI@CIN'], streaks);
expect(p.playerStreaks.map((s) => s.player)).toEqual(['Acuna']); // only ARI matches this game
});
test('no game lines → lines:null, not a crash', () => {
const p = svc.generateMatchupPreview(schedule[0], null, streaks);
expect(p.lines).toBeNull();
expect(p.narrative).toContain('@'.length ? '' : ''); // narrative present
expect(typeof p.narrative).toBe('string');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Content proxy (Session 29). Forwards /api/content/* to Express
* (slate thread / POTD / recap / matchup preview). Read-only, zero-credit.
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const segments = (path || []).map(encodeURIComponent).join('/');
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/content/${segments}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.status });
} catch {
return NextResponse.json({ error: 'Content service unreachable.' }, { status: 502 });
}
}