|
|
|
@@ -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, '&').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 `
|
|
|
|
|
<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,
|
|
|
|
|
},
|
|
|
|
|
};
|