Merge S7 (a1): newsletter — THE VYNDR REPORT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	BUILD-STATE.md
#	CLAUDE.md
This commit is contained in:
Kev
2026-07-11 14:32:18 -04:00
14 changed files with 1297 additions and 0 deletions
+3
View File
@@ -184,6 +184,9 @@ app.use('/api/books', bookComparisonRoutes);
// generated from live data, degrading gracefully by data level.
const contentRoutes = require('./routes/content');
app.use('/api/content', contentRoutes);
// Session S7 (a1) — THE VYNDR REPORT: public double-opt-in subscribe
// (forwards to the self-hosted Listmonk; graceful no-op without env).
app.use('/api/newsletter', require('./routes/newsletter'));
// 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
+26
View File
@@ -233,6 +233,32 @@ router.post('/ledger/settle', async (req, res) => {
}
});
/**
* POST /api/internal/newsletter/send (Session S7, a1) — assemble today's
* VYNDR REPORT from the pipeline (snapshot signals + streak lens + the
* ledger record) and send it as a Listmonk campaign to the opted-in list.
*
* DELIBERATELY UNSCHEDULED: nothing calls this on a timer. The operator
* (or a future n8n cron, once Kev arms it) triggers the send. Env-gated —
* without LISTMONK_* config it's a calm no-op; an empty report (zero
* signals AND zero streaks) refuses to send.
*
* Body (optional): { sports?: string[] } — defaults to ['mlb', 'wnba'].
*/
router.post('/newsletter/send', async (req, res) => {
const newsletter = require('../services/newsletterService');
const body = (req.body && typeof req.body === 'object') ? req.body : {};
const sports = Array.isArray(body.sports) && body.sports.length > 0 ? body.sports : undefined;
try {
const result = await newsletter.sendDailyReport({ sports });
return res.json(result);
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/newsletter/send] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
router.post('/outcomes/:sport', async (req, res) => {
const outcomes = require('../services/outcomeService');
try {
+54
View File
@@ -0,0 +1,54 @@
'use strict';
/**
* THE VYNDR REPORT — public subscribe endpoint (Session S7, a1 board).
*
* POST /api/newsletter/subscribe { email, website? }
*
* Forwards to the self-hosted Listmonk's subscriber API with double opt-in
* (preconfirm_subscriptions: false → Listmonk sends the confirmation email;
* nothing lands in the list until the visitor clicks it).
*
* Grace notes:
* - Without LISTMONK_* env this is a calm HTTP 200
* { ok: false, reason: 'not configured' } — the UI renders
* "signups open soon", never a scary error.
* - `website` is a honeypot (same trick as /api/waitlist): bots fill the
* hidden field, humans don't. Filled → silent { ok: true }.
* - Listmonk 409 (already subscribed) is { ok: true } — idempotent, and the
* response never reveals whether an address exists.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const newsletterService = require('../services/newsletterService');
const router = express.Router();
// Public + writes upstream → tight throttle (10/min per IP).
router.use(createRateLimit({ windowMs: 60_000, max: 10 }));
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
router.post('/subscribe', async (req, res) => {
const body = (req.body && typeof req.body === 'object') ? req.body : {};
// Honeypot — silently accept and drop.
if (body.website) return res.json({ ok: true });
const email = typeof body.email === 'string' ? body.email.trim() : '';
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
return res.status(400).json({ ok: false, error: 'Enter a valid email.' });
}
const result = await newsletterService.subscribe(email);
if (result.ok) return res.json({ ok: true });
if (result.reason === 'not configured') {
return res.json({ ok: false, reason: 'not configured' });
}
// Upstream hiccup — log it, keep the visitor's response calm.
console.error('[newsletter/subscribe] listmonk error:', result.reason);
return res.json({ ok: false, reason: 'unavailable' });
});
module.exports = router;
+337
View File
@@ -0,0 +1,337 @@
'use strict';
/**
* THE VYNDR REPORT (Session S7, a1 board) — daily newsletter assembly + send.
*
* Assembly is deterministic templating over EXISTING pipeline data (the Data
* Semantics Rule extends to marketing — numbers come only from the pipeline,
* never composed):
* - Signals: snapshot:{sport}:latest (the locked pre-graded slate)
* - STREAK WATCH: rosterLogs → streaksService → streakLens (the lens IS the content)
* - THE RECORD: ledgerService.getModelAggregate() — the n≥20 gate lives in
* the aggregate (hit_pct is null below MIN_AGG_SAMPLE); this
* template renders a percentage ONLY when hit_pct != null.
*
* VOICE v1.1: deadpan, no exclamation points, misses-included record, o/u for
* sides, ET timestamps. Template linting (unit tests) asserts the banned list.
*
* Send is Listmonk (self-hosted, same box — zero out-of-pocket), env-gated:
* without LISTMONK_* env every send path is a graceful no-op. One-click unsub
* is Listmonk-native — the literal {{ UnsubscribeURL }} placeholder below is
* substituted by Listmonk at send time, not by us.
*
* NOTHING here is scheduled. The send is operator-triggered via
* POST /api/internal/newsletter/send until Kev arms a cron.
*/
const { cacheGet } = require('../utils/redis');
const DEFAULT_SPORTS = ['mlb', 'wnba'];
const SIGNALS_PER_SPORT = 5;
const STREAKS_TOTAL = 3;
// ---------------------------------------------------------------- helpers
function listmonkConfig(env = process.env) {
const url = (env.LISTMONK_URL || '').replace(/\/+$/, '');
const user = env.LISTMONK_USER || '';
const token = env.LISTMONK_TOKEN || '';
const listId = parseInt(env.LISTMONK_LIST_ID || '', 10);
if (!url || !user || !token || !Number.isFinite(listId)) return null;
return { url, user, token, listId };
}
function authHeaders(cfg) {
// Listmonk API-user scheme (v2.4+): Authorization: token user:token
return {
'Content-Type': 'application/json',
Authorization: `token ${cfg.user}:${cfg.token}`,
};
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** "total_bases" → "total bases" — display form for stat keys. */
function statLabel(stat) {
return String(stat || '').toLowerCase().replace(/_/g, ' ').trim();
}
const sideChar = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o');
function etDateParts(now) {
const d = now instanceof Date ? now : new Date(now);
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric',
});
return fmt.format(d); // e.g. "Sat, Jul 11"
}
function etDateKey(now) {
const d = now instanceof Date ? now : new Date(now);
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
// ---------------------------------------------------------------- assembly
const GRADE_ORDER = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9 };
const gradeRank = (g) => (GRADE_ORDER[String(g || '').toUpperCase()] ?? 99);
/** One signal line: "Judge — total bases o1.5 · A+ · BOMBER" */
function signalLine(g) {
const stat = statLabel(g.stat_type || g.stat);
const side = sideChar(g.direction);
const bits = [`${g.player || g.player_name}${stat} ${side}${g.line}`, String(g.grade || '').toUpperCase()];
if (g.archetype) bits.push(String(g.archetype).toUpperCase());
return bits.join(' · ');
}
/** Pick each sport's top graded reads (best grade first, confidence tiebreak). */
function topSignals(grades, cap = SIGNALS_PER_SPORT) {
const rows = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade && (g.player || g.player_name));
rows.sort((a, b) => (gradeRank(a.grade) - gradeRank(b.grade)) || ((b.confidence || 0) - (a.confidence || 0)));
return rows.slice(0, cap);
}
/** One streak line, lens included: the streak + what it was built against + tonight. */
function streakLine(row) {
const read = row.lens && row.lens.read ? row.lens.read : row.description;
return `${row.player}${read}`;
}
/** THE RECORD line. The n≥20 gate is upstream (hit_pct null below sample). */
function recordLine(agg) {
if (!agg) return 'RECORD BUILDING · 0 pending';
if (agg.hit_pct == null) return `RECORD BUILDING · ${agg.pending || 0} pending`;
const decided = (agg.hits || 0) + (agg.misses || 0);
let line = `Last ${agg.window_days || 30} days: ${agg.hits}-${agg.misses} (${agg.hit_pct}%), misses included, n=${decided}`;
if (agg.beat_close_pct != null) line += ` · beat the close ${agg.beat_close_pct}%`;
return line;
}
/** RG + unsubscribe footer — baked into every send, html and text. */
const RG_TEXT_FOOTER = [
'21+. Gambling problem? Call or text 1-800-GAMBLER.',
'VYNDR is a data and analysis tool, not a sportsbook. No outcome is promised.',
'You are receiving this because you confirmed a subscription at vyndr.app.',
'One click to leave, no questions: {{ UnsubscribeURL }}',
].join('\n');
function rgHtmlFooter() {
return `
<div style="margin-top:28px;padding-top:14px;border-top:1px solid #1c2a26;color:#5b6f68;font-size:11px;line-height:1.7;">
21+. Gambling problem? Call or text 1-800-GAMBLER.<br/>
VYNDR is a data and analysis tool, not a sportsbook. No outcome is promised.<br/>
You are receiving this because you confirmed a subscription at vyndr.app.<br/>
One click to leave, no questions: <a href="{{ UnsubscribeURL }}" style="color:#5b6f68;">unsubscribe</a>
</div>`;
}
function sectionHtml(title, lines) {
if (!lines.length) return '';
const rows = lines.map((l) => `<div style="padding:3px 0;">${esc(l)}</div>`).join('\n');
return `
<div style="margin-top:22px;">
<div style="color:#00d4a0;font-size:11px;letter-spacing:0.14em;margin-bottom:8px;">${esc(title)}</div>
${rows}
</div>`;
}
/**
* Assemble the daily report from pipeline data. Every dependency is
* injectable; fixtures drive the unit tests, no network, no Redis.
*
* Returns { subject, html, text, counts: { signals, streaks, sports } }.
*/
async function buildDailyReport(sports = DEFAULT_SPORTS, deps = {}) {
const d = {
cacheGet,
loadRosterLogs: (sp) => require('./rosterLogs').loadRosterLogs(sp),
computeStreaks: (roster, sp, opts) => require('./streaksService').computeStreaks(roster, sp, opts),
applyLens: (rows, ctx) => require('./streakLens').applyLens(rows, ctx),
getModelAggregate: (opts) => require('./ledgerService').getModelAggregate(opts),
now: () => new Date(),
...deps,
};
const now = d.now();
const dateLabel = etDateParts(now);
// --- Signals: the locked snapshot per sport (cache-only, never triggers a run).
const signalSections = [];
let signalCount = 0;
const slateCounts = [];
for (const sport of sports) {
const sp = String(sport).toLowerCase();
let snap = null;
try { snap = await d.cacheGet(`snapshot:${sp}:latest`); } catch { snap = null; }
const grades = (snap && Array.isArray(snap.grades)) ? snap.grades : [];
if (grades.length > 0) slateCounts.push(`${grades.length} ${sp.toUpperCase()}`);
const top = topSignals(grades);
if (top.length > 0) {
signalCount += top.length;
signalSections.push({ title: `SIGNALS — ${sp.toUpperCase()}`, lines: top.map(signalLine) });
}
}
// --- STREAK WATCH: pure engines over cached logs, interpreted through the lens.
// Lens context is cache-only (schedule) — absent context = the lens says less.
let streakRows = [];
for (const sport of sports) {
const sp = String(sport).toLowerCase();
try {
const roster = await d.loadRosterLogs(sp);
const streaks = d.computeStreaks(roster, sp, { stat: 'all' });
let scheduleGames = [];
try {
const sched = await d.cacheGet(`schedule:${sp}:${etDateKey(now)}`);
if (Array.isArray(sched)) scheduleGames = sched;
} catch { /* say less */ }
streakRows = streakRows.concat(d.applyLens(streaks, { scheduleGames, pitcherGames: [] }));
} catch { /* an empty sport is a valid state */ }
}
streakRows.sort((a, b) => (b.currentStreak || 0) - (a.currentStreak || 0));
// One row per player (their longest streak wins) — signal-dense, no repeats.
const seenPlayers = new Set();
const streakLines = [];
for (const row of streakRows) {
const p = String(row.player || '').toLowerCase();
if (!p || seenPlayers.has(p)) continue;
seenPlayers.add(p);
streakLines.push(streakLine(row));
if (streakLines.length >= STREAKS_TOTAL) break;
}
// --- THE RECORD: n≥20 gate enforced upstream (hit_pct null below sample).
let agg = null;
try { agg = await d.getModelAggregate({}); } catch { agg = null; }
const record = recordLine(agg);
// --- Compose. Subject carries a number only when the pipeline gave us one.
const subject = signalCount > 0
? `THE VYNDR REPORT — ${dateLabel} · ${signalCount} signals`
: `THE VYNDR REPORT — ${dateLabel}`;
const wireLine = slateCounts.length > 0
? `THE WIRE — ${dateLabel}. ${slateCounts.join('. ')}.`
: `THE WIRE — ${dateLabel}. No graded slate at send time.`;
const textParts = [wireLine];
for (const s of signalSections) textParts.push('', s.title, ...s.lines);
if (streakLines.length) textParts.push('', 'STREAK WATCH', ...streakLines);
textParts.push('', 'THE RECORD', record, '', 'The slate, the signals, the settle. vyndr.app', '', RG_TEXT_FOOTER);
const text = textParts.join('\n');
const html = `
<div style="background:#07100d;color:#c9d8d2;font-family:'JetBrains Mono',ui-monospace,Menlo,monospace;font-size:13px;line-height:1.6;padding:28px 24px;max-width:560px;margin:0 auto;">
<div style="color:#e8fff4;font-size:15px;letter-spacing:0.18em;">THE VYNDR REPORT</div>
<div style="color:#5b6f68;font-size:11px;margin-top:2px;">${esc(wireLine)}</div>
${signalSections.map((s) => sectionHtml(s.title, s.lines)).join('\n')}
${sectionHtml('STREAK WATCH', streakLines)}
${sectionHtml('THE RECORD', [record])}
<div style="margin-top:22px;">
<a href="https://vyndr.app" style="color:#00d4a0;">The slate, the signals, the settle. vyndr.app</a>
</div>
${rgHtmlFooter()}
</div>`;
return {
subject,
html,
text,
counts: { signals: signalCount, streaks: streakLines.length, sports: sports.length },
};
}
// ------------------------------------------------------------------- send
/**
* Subscribe one email through Listmonk with double opt-in
* (preconfirm_subscriptions: false → Listmonk sends the confirmation).
* Env-gated: no LISTMONK_* env → { ok: false, reason: 'not configured' }.
* A 409 (already subscribed) is ok — idempotent, no enumeration.
*/
async function subscribe(email, opts = {}) {
const cfg = listmonkConfig(opts.env);
if (!cfg) return { ok: false, reason: 'not configured' };
const fetchImpl = opts.fetchImpl || globalThis.fetch;
try {
const res = await fetchImpl(`${cfg.url}/api/subscribers`, {
method: 'POST',
headers: authHeaders(cfg),
body: JSON.stringify({
email: String(email).toLowerCase().trim(),
name: '',
status: 'enabled',
lists: [cfg.listId],
preconfirm_subscriptions: false,
}),
});
if (res.ok || res.status === 409) return { ok: true };
return { ok: false, reason: `listmonk ${res.status}` };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'listmonk unreachable' };
}
}
/**
* Assemble today's report and send it as a Listmonk campaign (create, then
* start). Refuses to send an empty report — absent beats hollow. Env-gated
* no-op without Listmonk config. NOT scheduled anywhere; the internal route
* is the only caller until Kev arms a cron.
*/
async function sendDailyReport(opts = {}) {
const cfg = listmonkConfig(opts.env);
if (!cfg) return { ok: false, reason: 'not configured' };
const fetchImpl = opts.fetchImpl || globalThis.fetch;
const report = await buildDailyReport(opts.sports || DEFAULT_SPORTS, opts.deps || {});
if (report.counts.signals === 0 && report.counts.streaks === 0) {
return { ok: false, reason: 'empty report', report: { subject: report.subject, counts: report.counts } };
}
try {
const createRes = await fetchImpl(`${cfg.url}/api/campaigns`, {
method: 'POST',
headers: authHeaders(cfg),
body: JSON.stringify({
name: report.subject,
subject: report.subject,
lists: [cfg.listId],
type: 'regular',
content_type: 'html',
body: report.html,
altbody: report.text,
}),
});
if (!createRes.ok) return { ok: false, reason: `listmonk create ${createRes.status}` };
const created = await createRes.json().catch(() => ({}));
const campaignId = created && created.data && created.data.id;
if (!campaignId) return { ok: false, reason: 'listmonk create: no campaign id' };
const startRes = await fetchImpl(`${cfg.url}/api/campaigns/${campaignId}/status`, {
method: 'PUT',
headers: authHeaders(cfg),
body: JSON.stringify({ status: 'running' }),
});
if (!startRes.ok) return { ok: false, reason: `listmonk start ${startRes.status}`, campaignId };
return { ok: true, campaignId, subject: report.subject, counts: report.counts };
} catch (err) {
return { ok: false, reason: err && err.message ? err.message : 'listmonk unreachable' };
}
}
module.exports = {
buildDailyReport,
sendDailyReport,
subscribe,
__internals: {
listmonkConfig, authHeaders, statLabel, signalLine, topSignals,
streakLine, recordLine, etDateParts, etDateKey, RG_TEXT_FOOTER,
DEFAULT_SPORTS,
},
};