S7 (a1): newsletter — THE VYNDR REPORT

Email capture + daily report assembly + operator-triggered Listmonk send.

- NewsletterCapture (dark terminal, mono) on landing (below FAQ) + /welcome
  (the real signup success surface); double-opt-in note; 'Signups open soon'
  when Listmonk env is unset.
- POST /api/newsletter/subscribe: public, 10/min IP limit, honeypot,
  server-side email validation, forwards to Listmonk subscribers API with
  preconfirm_subscriptions:false (Listmonk sends the confirmation).
  No env -> calm 200 { ok:false, reason:'not configured' }. Next proxy
  web/src/app/api/newsletter/subscribe/route.ts (S25 rule).
- newsletterService.buildDailyReport: signals from snapshot:{sport}:latest,
  STREAK WATCH via rosterLogs -> streaksService -> streakLens, THE RECORD via
  ledgerService.getModelAggregate (percentage only when hit_pct != null —
  n>=20 gate — else 'RECORD BUILDING · N pending'). RG footer (21+,
  1-800-GAMBLER, Listmonk-native {{ UnsubscribeURL }}) in html + text.
  VOICE v1.1 lint locked by tests: no '!', no banned vocabulary, numbers
  only from injected pipeline data.
- sendDailyReport: creates + starts a Listmonk campaign; env-gated no-op;
  refuses an empty report. Deliberately UNSCHEDULED — only
  POST /api/internal/newsletter/send (internal key) triggers it.
- docs/NEWSLETTER.md: box-side Listmonk runbook (install, double-opt-in
  list, API user, Coolify env, test-send).
- Spec: specs/feature-a1-s7-newsletter.md.

Tests 2398 -> 2429 (207 suites, all green); next build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 14:29:22 -04:00
parent e4d2e79f95
commit 32d7200571
14 changed files with 1300 additions and 1 deletions
+26
View File
@@ -214,6 +214,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 {