'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, '&').replace(//g, '>'); } /** "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 `
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: unsubscribe
`; } function sectionHtml(title, lines) { if (!lines.length) return ''; const rows = lines.map((l) => `
${esc(l)}
`).join('\n'); return `
${esc(title)}
${rows}
`; } /** * 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 = `
THE VYNDR REPORT
${esc(wireLine)}
${signalSections.map((s) => sectionHtml(s.title, s.lines)).join('\n')} ${sectionHtml('STREAK WATCH', streakLines)} ${sectionHtml('THE RECORD', [record])}
The slate, the signals, the settle. vyndr.app
${rgHtmlFooter()}
`; 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, }, };