From 32d72005719e91f66cdae4010d2bc73ea091c854 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 14:29:22 -0400 Subject: [PATCH] =?UTF-8?q?S7=20(a1):=20newsletter=20=E2=80=94=20THE=20VYN?= =?UTF-8?q?DR=20REPORT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BUILD-STATE.md | 30 +- CLAUDE.md | 29 ++ docs/NEWSLETTER.md | 112 ++++++ specs/feature-a1-s7-newsletter.md | 84 +++++ src/app.js | 3 + src/routes/internal.js | 26 ++ src/routes/newsletter.js | 54 +++ src/services/newsletterService.js | 337 ++++++++++++++++++ tests/integration/newsletterRoute.test.js | 149 ++++++++ tests/unit/newsletterService.test.js | 275 ++++++++++++++ web/src/app/api/newsletter/subscribe/route.ts | 35 ++ web/src/app/page.tsx | 6 + web/src/app/welcome/page.tsx | 9 + web/src/components/NewsletterCapture.tsx | 152 ++++++++ 14 files changed, 1300 insertions(+), 1 deletion(-) create mode 100644 docs/NEWSLETTER.md create mode 100644 specs/feature-a1-s7-newsletter.md create mode 100644 src/routes/newsletter.js create mode 100644 src/services/newsletterService.js create mode 100644 tests/integration/newsletterRoute.test.js create mode 100644 tests/unit/newsletterService.test.js create mode 100644 web/src/app/api/newsletter/subscribe/route.ts create mode 100644 web/src/components/NewsletterCapture.tsx diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 7dd0707..6e81607 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,7 +1,35 @@ # VYNDR — Build State ## Last Updated -2026-07-10 +2026-07-11 + +## Session S7 (a1 board, 2026-07-11) — Newsletter: THE VYNDR REPORT ✅ + +Branch off day1/a1-board (e4d2e79). 2398 → **2429 tests** (207 suites), +web build exit 0. Spec: `specs/feature-a1-s7-newsletter.md`. Runbook: +`docs/NEWSLETTER.md`. +- **Capture**: `NewsletterCapture` (dark terminal, mono data) on the landing + (below FAQ) + `/welcome` (the real signup success surface — the signup + "done" card auto-redirects in 1.5s, useless for a form). Double-opt-in + note under the field; "Signups open soon" when Listmonk env is unset. +- **Backend**: `POST /api/newsletter/subscribe` (public, 10/min IP limit, + honeypot, server-side email validation) → 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). +- **Assembly**: `newsletterService.buildDailyReport` — signals from + `snapshot:{sport}:latest`, STREAK WATCH via rosterLogs → streaksService → + streakLens (one row per player), THE RECORD via `getModelAggregate` + (percentage ONLY when hit_pct != null — the n≥20 gate — else + "RECORD BUILDING · N pending"). RG footer (21+, 1-800-GAMBLER, + `{{ UnsubscribeURL }}`) in html + text. VOICE lint locked by tests: + no `!`, no banned vocabulary, numbers only from injected pipeline data. +- **Send**: `sendDailyReport` creates + starts a Listmonk campaign; + operator-triggered ONLY via `POST /api/internal/newsletter/send` + (internal key). NOT scheduled. Empty report (0 signals + 0 streaks) + refuses to send. +- **Env for Coolify** (all four required, else graceful no-op): + `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`. ## Current Phase SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, diff --git a/CLAUDE.md b/CLAUDE.md index 0a4ee14..6671c0d 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -801,6 +801,35 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and schedule-derived waiting copy. Keep them green. +## Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious) +- **`newsletterService`** is the whole layer: `buildDailyReport(sports, deps)` + (deterministic VOICE v1.1 template over snapshot grades + streak lens + + `getModelAggregate`; every dep injectable), `subscribe(email)` and + `sendDailyReport(opts)` (Listmonk, self-hosted, zero out-of-pocket). ALL + Listmonk paths are env-gated on `LISTMONK_URL/USER/TOKEN/LIST_ID` — any + missing → `{ ok:false, reason:'not configured' }`, and the capture UI shows + "Signups open soon". Auth is the API-user scheme `Authorization: token + user:token`, NOT basic auth. +- **Double opt-in is Listmonk's job:** subscribe sends + `preconfirm_subscriptions:false`; the list itself must be created as + double-opt-in (runbook: `docs/NEWSLETTER.md`). The email template embeds the + LITERAL `{{ UnsubscribeURL }}` — Listmonk substitutes per-recipient; never + "fix" it. A 409 (already subscribed) is returned as ok — no enumeration. +- **The send is deliberately unscheduled.** Only `POST + /api/internal/newsletter/send` (internal key) triggers it; an empty report + (0 signals AND 0 streaks) refuses to send. The record line renders a + percentage ONLY when `hit_pct != null` (n≥20 gate upstream in + `getModelAggregate`), else "RECORD BUILDING · N pending". +- **VOICE lint is executable:** `tests/unit/newsletterService.test.js` fails on + any `!`, banned vocabulary (word-boundary regex), or hype emoji in + subject/html/text. The RG disclaimer says "No outcome is promised" — the word + "guarantee" trips the lint even in a disclaimer, keep it out. +- **Capture placement:** the signup "done" card auto-redirects after 1.5s + (`setTimeout(router.replace)`) — useless for a form. The free-signup success + surface is `/welcome`. Landing capture sits below FAQ. Subscribe route is + 10/min IP-limited → keep `tests/integration/newsletterRoute.test.js` under 10 + subscribe requests (same lesson as analyze.test.js). + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/docs/NEWSLETTER.md b/docs/NEWSLETTER.md new file mode 100644 index 0000000..f37257a --- /dev/null +++ b/docs/NEWSLETTER.md @@ -0,0 +1,112 @@ +# THE VYNDR REPORT — Listmonk runbook (Session S7, a1) + +The daily newsletter: the slate, the signals, the settle. Assembly is code +(`src/services/newsletterService.js` — numbers only from the pipeline, VOICE +v1.1, RG footer baked in). Delivery is Listmonk, self-hosted on the same +Hetzner box — zero out-of-pocket. Without the env vars below, every +newsletter path is a graceful no-op: the subscribe endpoint answers +`{ ok: false, reason: 'not configured' }` and the capture UI says +"Signups open soon." + +Nothing is scheduled. The send is operator-triggered via the internal route +until Kev arms a cron. + +## 1. Deploy Listmonk on the box (Coolify) + +Listmonk is a single Go binary + Postgres. In Coolify: new service → +Docker image `listmonk/listmonk:latest` + a Postgres 16 resource +(or reuse an existing Postgres with a fresh `listmonk` database). + +Minimum container env: + +``` +LISTMONK_app__address=0.0.0.0:9000 +LISTMONK_db__host= +LISTMONK_db__port=5432 +LISTMONK_db__user=listmonk +LISTMONK_db__password= +LISTMONK_db__database=listmonk +``` + +First boot needs the schema install: run `./listmonk --install` once +(Coolify: one-off command on the container), then start normally. Log in at +the service URL, change the default `listmonk` admin password immediately. + +Configure SMTP under Settings → SMTP (any relay you already have; without a +working SMTP block the double-opt-in confirmations never leave the box — +test this first). + +## 2. Create the list + +Admin UI → Lists → New: +- Name: `THE VYNDR REPORT` +- Type: **private** +- Opt-in: **double** ← required. The API subscribe call sends + `preconfirm_subscriptions: false`; Listmonk then emails the confirmation. + Nothing lands on the list until the reader clicks it. + +Note the numeric list id shown in the UI/URL — that is `LISTMONK_LIST_ID`. + +## 3. Create an API user (never use the admin login for the app) + +Admin UI → Settings → Users → New: +- Username: `vyndr-api` +- Type: API user → Listmonk generates a token. Copy it once; it is not shown again. +- Role: needs `subscribers:manage` + `campaigns:manage` + `campaigns:get` + (or a role scoped to exactly those). + +The app authenticates with `Authorization: token vyndr-api:`. + +## 4. Env vars for the VYNDR backend (Coolify → the Express app) + +``` +LISTMONK_URL=http://:9000 # same box — use the internal network, not the public URL +LISTMONK_USER=vyndr-api +LISTMONK_TOKEN= +LISTMONK_LIST_ID= +``` + +All four are required; any missing → the whole layer no-ops (deploy-safe). + +## 5. Template note — one-click unsubscribe + +The assembled email embeds the literal `{{ UnsubscribeURL }}` placeholder in +its RG footer. Listmonk substitutes it per-recipient at send time — do not +"fix" it in the service. The RG footer (21+, 1-800-GAMBLER, unsubscribe note) +is baked into `newsletterService` and asserted by +`tests/unit/newsletterService.test.js`; every send carries it. + +## 6. Test-send steps + +1. Subscribe yourself through the product: the landing-page capture (or + `curl -X POST https://vyndr.app/api/newsletter/subscribe -H 'Content-Type: application/json' -d '{"email":"kevdevelops@gmail.com"}'`). +2. Confirm the double-opt-in email arrives and click it — you should show as + `confirmed` on the list in the Listmonk UI. +3. Fire a real send (internal key, operator-triggered): + +``` +curl -X POST https:///api/internal/newsletter/send \ + -H "x-internal-key: $VYNDR_INTERNAL_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"sports":["mlb","wnba"]}' +``` + +Responses: +- `{ ok: true, campaignId, subject, counts }` — sent; check the inbox + + Listmonk → Campaigns for delivery stats. +- `{ ok: false, reason: 'empty report' }` — zero signals AND zero streaks at + send time (off-hours / cold caches). Deliberate: an empty email never sends. + Run after the morning snapshot (14:00 UTC) so the slate is graded. +- `{ ok: false, reason: 'not configured' }` — env missing on the backend. + +4. Verify the received email: mono layout, signals match `/api/snapshot/mlb`, + the record line matches `/api/ledger/accuracy` gating (a percentage only at + n≥20, otherwise `RECORD BUILDING · N pending`), RG footer present, and the + unsubscribe link is a working per-recipient Listmonk URL. + +## 7. Scheduling (NOT armed) + +When Kev decides to arm the daily send, point an n8n cron (or the box +crontab) at the internal route above — e.g. daily at 15:30 UTC, after the +14:00 UTC snapshot has graded the slate. There is deliberately no in-process +scheduler for this; email to subscribers stays operator-controlled until then. diff --git a/specs/feature-a1-s7-newsletter.md b/specs/feature-a1-s7-newsletter.md new file mode 100644 index 0000000..3a48909 --- /dev/null +++ b/specs/feature-a1-s7-newsletter.md @@ -0,0 +1,84 @@ +# Feature A1-S7 — Newsletter: THE VYNDR REPORT + +## Goal +A daily email — the slate, the signals, the settle — for opted-in subscribers. +Zero out-of-pocket: Listmonk is self-hosted on the same Hetzner box. VOICE v1.1 +governs every line. Nothing auto-posts; the send is operator-triggered through +an internal route until Kev arms a schedule. + +## Endpoints + +### POST /api/newsletter/subscribe (public, rate-limited 10/min per IP) +Body: `{ email: string, website?: string }` (`website` = honeypot; bots filled → silent `{ ok: true }`). +- Valid email + Listmonk env set → forward to Listmonk subscriber API with + `preconfirm_subscriptions: false` (Listmonk sends the double-opt-in + confirmation). Response: `{ ok: true }` (also on Listmonk 409 = already + subscribed — idempotent, no enumeration). +- Env missing → HTTP 200 `{ ok: false, reason: 'not configured' }` (the UI + says "signups open soon" — never a scary error). +- Invalid email → 400 `{ ok: false, error: 'Enter a valid email.' }`. + +Env: `LISTMONK_URL`, `LISTMONK_USER`, `LISTMONK_TOKEN`, `LISTMONK_LIST_ID`. +Auth header: `Authorization: token USER:TOKEN` (Listmonk API-user scheme). + +### POST /api/internal/newsletter/send (requireInternalAuth) +Body: `{ sports?: string[] }` (default `['mlb', 'wnba']`). Assembles the daily +report and creates + starts a Listmonk campaign. Returns the assembly summary. +Refuses (`ok: false, reason: 'empty report'`) when there are zero signals AND +zero streaks — an empty email never sends. NOT scheduled anywhere; the operator +(or a future n8n cron) calls this. + +### Next proxy: web/src/app/api/newsletter/subscribe/route.ts +S25 rule — forwards POST to `${BACKEND_URL}/api/newsletter/subscribe`. On +upstream failure returns 200 `{ ok: false, reason: 'not configured' }` so the +visitor sees the graceful "soon" state. + +## Data shapes + +`newsletterService.buildDailyReport(sports, deps)` → `{ subject, html, text, counts: { signals, streaks, sports } }` +- Signals: `cacheGet('snapshot:{sport}:latest')` → grades, top 5 per sport by + confidence (A-tier first). Numbers only from the pipeline. +- STREAK WATCH: `loadRosterLogs` → `streaksService.computeStreaks` → + `streakLens.applyLens` (lens ctx = cached schedule only; absent = say less). + Top 3 across sports by streak length. +- THE RECORD: `ledgerService.getModelAggregate()` — render the record line + ONLY when `hit_pct != null` (the n≥20 gate lives in the aggregate); + otherwise `RECORD BUILDING · N pending`. +- All deps injectable; unit tests use fixtures, no network, no Redis. + +`sendDailyReport(opts)` — env-gated (`ok:false, reason:'not configured'` +without Listmonk env), POSTs `/api/campaigns` then PUT status `running`. +`fetchImpl` injectable. + +## VOICE / compliance invariants (asserted by tests) +- No `!` anywhere in subject, html, or text. +- Banned vocabulary never appears: "lock", "tail"/"fade" as CTAs, hype emoji. +- Record percentage renders only under the n≥20 gate (hit_pct null → building line). +- RG footer in both html + text: 21+, 1-800-GAMBLER, unsubscribe note. +- One-click unsub is Listmonk-native: literal `{{ UnsubscribeURL }}` placeholder + in the template (Listmonk substitutes at send time). + +## Frontend +`web/src/components/NewsletterCapture.tsx` — dark-terminal styled, mono data +font. Copy: "THE VYNDR REPORT — the slate, the signals, the settle. Daily. +Free." Double-opt-in note under the field. States: idle / busy / sent +("Confirmation sent. Check your inbox.") / soon ("Signups open soon."). +Mounted on the landing page (after FAQ, near the footer) and on the signup +success card (the free-signup success path). + +## Acceptance criteria +1. POST /api/newsletter/subscribe with no env → 200 `{ ok:false, reason:'not configured' }`. +2. With env (mocked fetch) → Listmonk called with `preconfirm_subscriptions:false`, list id from env, `token` auth header; 409 treated as ok. +3. Invalid email → 400; honeypot → silent ok. +4. buildDailyReport output passes the VOICE lint (no `!`, no banned words) and includes the RG footer + `{{ UnsubscribeURL }}`. +5. Record line gated: hit_pct null → "RECORD BUILDING · N pending"; hit_pct present → the percentage renders. +6. sendDailyReport without env = no-op; empty report never sends. +7. Full `npx jest` green; `cd web && npx next build` exit 0. + +## Test plan +- `tests/unit/newsletterService.test.js` — fixtures for snapshot grades, + roster logs, aggregate; VOICE lint; gates; RG footer; send no-op + campaign + create/start via injected fetch; empty-report refusal. +- `tests/integration/newsletterRoute.test.js` — supertest against app with + mocked global fetch + redis mock; ≤10 requests total (the 10/min limiter has + no reset hook — same lesson as analyze.test.js). diff --git a/src/app.js b/src/app.js index 5d0f905..4664a2f 100644 --- a/src/app.js +++ b/src/app.js @@ -182,6 +182,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 diff --git a/src/routes/internal.js b/src/routes/internal.js index e83ce59..6b32650 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -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 { diff --git a/src/routes/newsletter.js b/src/routes/newsletter.js new file mode 100644 index 0000000..28ce0ed --- /dev/null +++ b/src/routes/newsletter.js @@ -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; diff --git a/src/services/newsletterService.js b/src/services/newsletterService.js new file mode 100644 index 0000000..1a318f4 --- /dev/null +++ b/src/services/newsletterService.js @@ -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, '>'); +} + +/** "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])} + + ${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, + }, +}; diff --git a/tests/integration/newsletterRoute.test.js b/tests/integration/newsletterRoute.test.js new file mode 100644 index 0000000..e88401c --- /dev/null +++ b/tests/integration/newsletterRoute.test.js @@ -0,0 +1,149 @@ +'use strict'; + +// Session S7 (a1) — THE VYNDR REPORT routes. Redis is mocked (app.js pulls it +// in transitively); Listmonk is a mocked global fetch. NOTE: the subscribe +// route sits behind a 10/min IP limiter with no reset hook (same lesson as +// analyze.test.js) — this file stays well under 10 subscribe requests. + +const request = require('supertest'); + +let mockStore = {}; +jest.mock('../../src/utils/redis', () => ({ + getRedisClient: () => ({}), + cacheGet: async (k) => (k in mockStore ? mockStore[k] : null), + cacheSet: async (k, v) => { mockStore[k] = v; return true; }, + cacheDel: async () => true, + isDegraded: () => false, +})); + +const app = require('../../src/app'); + +const LISTMONK_ENV = { + LISTMONK_URL: 'http://127.0.0.1:9000', + LISTMONK_USER: 'vyndr-api', + LISTMONK_TOKEN: 'tok123', + LISTMONK_LIST_ID: '3', +}; +const ENV_KEYS = [...Object.keys(LISTMONK_ENV), 'VYNDR_INTERNAL_KEY']; + +const savedEnv = {}; +beforeAll(() => { for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; }); +afterAll(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +const realFetch = globalThis.fetch; +beforeEach(() => { + mockStore = {}; + for (const k of ENV_KEYS) delete process.env[k]; +}); +afterEach(() => { globalThis.fetch = realFetch; }); + +describe('POST /api/newsletter/subscribe', () => { + test('no Listmonk env → calm HTTP 200 not-configured (never a scary error)', async () => { + globalThis.fetch = jest.fn(); + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'kev@example.com' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: false, reason: 'not configured' }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('honeypot filled → silent ok, nothing forwarded', async () => { + Object.assign(process.env, LISTMONK_ENV); + globalThis.fetch = jest.fn(); + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'bot@example.com', website: 'http://spam.example' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('invalid email → 400 with a plain message', async () => { + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'not-an-email' }); + expect(res.status).toBe(400); + expect(res.body.ok).toBe(false); + expect(res.body.error).toBe('Enter a valid email.'); + }); + + test('env set → forwards to Listmonk with double opt-in (preconfirm false)', async () => { + Object.assign(process.env, LISTMONK_ENV); + globalThis.fetch = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })); + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'Kev@Example.com' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const [url, opts] = globalThis.fetch.mock.calls[0]; + expect(url).toBe('http://127.0.0.1:9000/api/subscribers'); + expect(opts.headers.Authorization).toBe('token vyndr-api:tok123'); + const body = JSON.parse(opts.body); + expect(body.email).toBe('kev@example.com'); + expect(body.lists).toEqual([3]); + expect(body.preconfirm_subscriptions).toBe(false); + }); + + test('Listmonk 409 (already subscribed) → still ok, no enumeration', async () => { + Object.assign(process.env, LISTMONK_ENV); + globalThis.fetch = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) })); + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'kev@example.com' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); + + test('Listmonk down → calm HTTP 200 unavailable', async () => { + Object.assign(process.env, LISTMONK_ENV); + globalThis.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); }); + const res = await request(app) + .post('/api/newsletter/subscribe') + .send({ email: 'kev@example.com' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: false, reason: 'unavailable' }); + }); +}); + +describe('POST /api/internal/newsletter/send', () => { + test('requires the internal key', async () => { + process.env.VYNDR_INTERNAL_KEY = 'shh'; + const res = await request(app).post('/api/internal/newsletter/send').send({}); + expect(res.status).toBe(401); + }); + + test('with key but no Listmonk env → graceful no-op (operator sees why)', async () => { + process.env.VYNDR_INTERNAL_KEY = 'shh'; + globalThis.fetch = jest.fn(); + const res = await request(app) + .post('/api/internal/newsletter/send') + .set('x-internal-key', 'shh') + .send({}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: false, reason: 'not configured' }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + test('with key + env but an empty pipeline → refuses to send an empty report', async () => { + process.env.VYNDR_INTERNAL_KEY = 'shh'; + Object.assign(process.env, LISTMONK_ENV); + globalThis.fetch = jest.fn(); + // mockStore is empty → no snapshots, no roster logs → empty report. + const res = await request(app) + .post('/api/internal/newsletter/send') + .set('x-internal-key', 'shh') + .send({ sports: ['mlb'] }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(false); + expect(res.body.reason).toBe('empty report'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/newsletterService.test.js b/tests/unit/newsletterService.test.js new file mode 100644 index 0000000..aec03b3 --- /dev/null +++ b/tests/unit/newsletterService.test.js @@ -0,0 +1,275 @@ +'use strict'; + +// Session S7 (a1) — THE VYNDR REPORT assembly + send. Everything injected: +// fixtures stand in for the snapshot cache, the streaks engine inputs, and +// the ledger aggregate. No network, no Redis. + +const { + buildDailyReport, + sendDailyReport, + subscribe, + __internals, +} = require('../../src/services/newsletterService'); + +// ---------------------------------------------------------------- fixtures + +const SNAPSHOT_MLB = { + sport: 'mlb', + updated_at: '2026-07-11T14:00:00Z', + grades: [ + { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A+', confidence: 82, archetype: 'BOMBER' }, + { player: 'Mookie Betts', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 64 }, + { player: 'Luis Castillo', stat_type: 'pitcher_strikeouts', line: 6.5, direction: 'under', grade: 'A', confidence: 71, archetype: 'WHIFF' }, + ], + deltas: [], +}; + +const ROSTER_MLB = [ + { + name: 'Seiya Suzuki', + team: 'Chicago Cubs', + games: [ + { date: '2026-07-10', hits: 2, opponent: 'STL' }, + { date: '2026-07-09', hits: 1, opponent: 'STL' }, + { date: '2026-07-08', hits: 3, opponent: 'PIT' }, + { date: '2026-07-07', hits: 1, opponent: 'PIT' }, + { date: '2026-07-06', hits: 2, opponent: 'CIN' }, + { date: '2026-07-05', hits: 0, opponent: 'CIN' }, + ], + }, +]; + +const AGG_BUILDING = { window_days: 30, min_sample: 20, settled: 4, hits: 3, misses: 1, pushes: 0, hit_pct: null, pending: 17, beat_close_pct: null }; +const AGG_REAL = { window_days: 30, min_sample: 20, settled: 25, hits: 15, misses: 9, pushes: 1, hit_pct: 63, pending: 6, beat_close_pct: 58 }; + +function deps(overrides = {}) { + return { + cacheGet: async (key) => (key === 'snapshot:mlb:latest' ? SNAPSHOT_MLB : null), + loadRosterLogs: async (sport) => (sport === 'mlb' ? ROSTER_MLB : []), + computeStreaks: require('../../src/services/streaksService').computeStreaks, + applyLens: require('../../src/services/streakLens').applyLens, + getModelAggregate: async () => AGG_BUILDING, + now: () => new Date('2026-07-11T15:00:00Z'), + ...overrides, + }; +} + +// VOICE v1.1 lint — the banned list the templates must never emit. +// "lock"/"tail"/"fade" as words (not substrings — "blocked"/"detail" are fine). +const BANNED_WORDS = /\b(lock|locks|tail|tails|fade|fades|guarantee[ds]?|free money|can't lose)\b/i; +const HYPE_EMOJI = /[\u{1F525}\u{1F4B0}\u{1F680}\u{1F4AF}\u{1F512}]/u; + +describe('buildDailyReport — VOICE lint', () => { + let report; + beforeAll(async () => { + report = await buildDailyReport(['mlb'], deps()); + }); + + test('no exclamation points anywhere — subject, html, text', () => { + expect(report.subject).not.toContain('!'); + expect(report.text).not.toContain('!'); + // html may contain !important? we never use it — assert none at all + expect(report.html).not.toContain('!'); + }); + + test('no banned vocabulary, no hype emoji', () => { + for (const part of [report.subject, report.text, report.html]) { + expect(part).not.toMatch(BANNED_WORDS); + expect(part).not.toMatch(HYPE_EMOJI); + } + }); + + test('subject carries the pipeline signal count and the ET date', () => { + expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11 · 3 signals'); + }); + + test('signals come only from the snapshot fixture, o/u for sides', () => { + expect(report.text).toContain('Aaron Judge — total bases o1.5 · A+ · BOMBER'); + expect(report.text).toContain('Luis Castillo — pitcher strikeouts u6.5 · A · WHIFF'); + expect(report.counts.signals).toBe(3); + }); + + test('A+ sorts ahead of B regardless of array order', () => { + const judge = report.text.indexOf('Aaron Judge'); + const betts = report.text.indexOf('Mookie Betts'); + expect(judge).toBeGreaterThan(-1); + expect(betts).toBeGreaterThan(judge); + }); + + test('STREAK WATCH renders the real computed streak through the lens', () => { + expect(report.text).toContain('STREAK WATCH'); + expect(report.text).toContain('Seiya Suzuki'); + // Lens: built-vs opponents from the game log — real, not composed. + expect(report.text).toMatch(/built vs/); + expect(report.counts.streaks).toBeGreaterThan(0); + }); + + test('RG footer present in html AND text: 21+, 1-800-GAMBLER, unsubscribe', () => { + for (const part of [report.text, report.html]) { + expect(part).toContain('21+'); + expect(part).toContain('1-800-GAMBLER'); + expect(part).toContain('{{ UnsubscribeURL }}'); + } + }); +}); + +describe('buildDailyReport — the record gate (n≥20 lives upstream)', () => { + test('hit_pct null → RECORD BUILDING with the pending count, never a %', async () => { + const report = await buildDailyReport(['mlb'], deps()); + expect(report.text).toContain('RECORD BUILDING · 17 pending'); + expect(report.text).not.toMatch(/\d+%.*misses included/); + }); + + test('hit_pct present → the real record line, misses included', async () => { + const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => AGG_REAL })); + expect(report.text).toContain('Last 30 days: 15-9 (63%), misses included, n=24'); + expect(report.text).toContain('beat the close 58%'); + expect(report.text).not.toContain('RECORD BUILDING'); + }); + + test('aggregate throws → building line, never a crash', async () => { + const report = await buildDailyReport(['mlb'], deps({ getModelAggregate: async () => { throw new Error('supabase down'); } })); + expect(report.text).toContain('RECORD BUILDING · 0 pending'); + }); +}); + +describe('buildDailyReport — empty pipeline is a valid state', () => { + test('no snapshot, no roster → honest empty wire, zero counts', async () => { + const report = await buildDailyReport(['mlb'], deps({ + cacheGet: async () => null, + loadRosterLogs: async () => [], + })); + expect(report.counts.signals).toBe(0); + expect(report.counts.streaks).toBe(0); + expect(report.text).toContain('No graded slate at send time'); + expect(report.subject).toBe('THE VYNDR REPORT — Sat, Jul 11'); // no invented number + // Compliance footer still present even on an empty report. + expect(report.text).toContain('1-800-GAMBLER'); + }); +}); + +describe('sendDailyReport — env-gated, empty-refusing', () => { + const ENV = { + LISTMONK_URL: 'http://127.0.0.1:9000', + LISTMONK_USER: 'vyndr-api', + LISTMONK_TOKEN: 'tok123', + LISTMONK_LIST_ID: '3', + }; + + test('no env → graceful no-op, no fetch', async () => { + const fetchImpl = jest.fn(); + const res = await sendDailyReport({ env: {}, fetchImpl, deps: deps() }); + expect(res).toEqual({ ok: false, reason: 'not configured' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('empty report → refuses to send even with env set', async () => { + const fetchImpl = jest.fn(); + const res = await sendDailyReport({ + env: ENV, fetchImpl, + deps: deps({ cacheGet: async () => null, loadRosterLogs: async () => [] }), + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe('empty report'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('creates then starts the campaign with the token auth scheme', async () => { + const calls = []; + const fetchImpl = jest.fn(async (url, opts) => { + calls.push({ url, opts }); + if (url.endsWith('/api/campaigns')) { + return { ok: true, status: 200, json: async () => ({ data: { id: 42 } }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() }); + expect(res.ok).toBe(true); + expect(res.campaignId).toBe(42); + + const create = calls[0]; + expect(create.url).toBe('http://127.0.0.1:9000/api/campaigns'); + expect(create.opts.headers.Authorization).toBe('token vyndr-api:tok123'); + const body = JSON.parse(create.opts.body); + expect(body.lists).toEqual([3]); + expect(body.subject).toContain('THE VYNDR REPORT'); + expect(body.body).toContain('{{ UnsubscribeURL }}'); + expect(body.altbody).toContain('1-800-GAMBLER'); + + const start = calls[1]; + expect(start.url).toBe('http://127.0.0.1:9000/api/campaigns/42/status'); + expect(JSON.parse(start.opts.body)).toEqual({ status: 'running' }); + }); + + test('listmonk create failure → honest error, no start call', async () => { + const fetchImpl = jest.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })); + const res = await sendDailyReport({ env: ENV, fetchImpl, deps: deps() }); + expect(res.ok).toBe(false); + expect(res.reason).toBe('listmonk create 500'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + +describe('subscribe — double opt-in forwarding', () => { + const ENV = { + LISTMONK_URL: 'http://127.0.0.1:9000/', + LISTMONK_USER: 'vyndr-api', + LISTMONK_TOKEN: 'tok123', + LISTMONK_LIST_ID: '3', + }; + + test('no env → not configured, no fetch', async () => { + const fetchImpl = jest.fn(); + const res = await subscribe('kev@example.com', { env: {}, fetchImpl }); + expect(res).toEqual({ ok: false, reason: 'not configured' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('forwards with preconfirm_subscriptions:false (Listmonk sends the confirmation)', async () => { + const fetchImpl = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })); + const res = await subscribe('KEV@Example.com ', { env: ENV, fetchImpl }); + expect(res).toEqual({ ok: true }); + const [url, opts] = fetchImpl.mock.calls[0]; + expect(url).toBe('http://127.0.0.1:9000/api/subscribers'); // trailing slash trimmed + const body = JSON.parse(opts.body); + expect(body.email).toBe('kev@example.com'); + expect(body.preconfirm_subscriptions).toBe(false); + expect(body.lists).toEqual([3]); + expect(body.status).toBe('enabled'); + }); + + test('409 already-subscribed is ok (idempotent, no enumeration)', async () => { + const fetchImpl = jest.fn(async () => ({ ok: false, status: 409, json: async () => ({}) })); + const res = await subscribe('kev@example.com', { env: ENV, fetchImpl }); + expect(res).toEqual({ ok: true }); + }); + + test('network failure → calm reason, never throws', async () => { + const fetchImpl = jest.fn(async () => { throw new Error('ECONNREFUSED'); }); + const res = await subscribe('kev@example.com', { env: ENV, fetchImpl }); + expect(res.ok).toBe(false); + expect(res.reason).toBe('ECONNREFUSED'); + }); +}); + +describe('__internals', () => { + test('listmonkConfig requires all four env vars', () => { + expect(__internals.listmonkConfig({})).toBeNull(); + expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't' })).toBeNull(); + expect(__internals.listmonkConfig({ LISTMONK_URL: 'x', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: 'nope' })).toBeNull(); + expect(__internals.listmonkConfig({ LISTMONK_URL: 'http://x/', LISTMONK_USER: 'u', LISTMONK_TOKEN: 't', LISTMONK_LIST_ID: '7' })) + .toEqual({ url: 'http://x', user: 'u', token: 't', listId: 7 }); + }); + + test('recordLine gates on hit_pct, not on settled count', () => { + expect(__internals.recordLine(null)).toBe('RECORD BUILDING · 0 pending'); + expect(__internals.recordLine({ hit_pct: null, pending: 12 })).toBe('RECORD BUILDING · 12 pending'); + expect(__internals.recordLine({ hit_pct: 61, hits: 14, misses: 9, window_days: 30, beat_close_pct: null })) + .toBe('Last 30 days: 14-9 (61%), misses included, n=23'); + }); + + test('statLabel humanizes stat keys', () => { + expect(__internals.statLabel('total_bases')).toBe('total bases'); + expect(__internals.statLabel('PITCHER_STRIKEOUTS')).toBe('pitcher strikeouts'); + }); +}); diff --git a/web/src/app/api/newsletter/subscribe/route.ts b/web/src/app/api/newsletter/subscribe/route.ts new file mode 100644 index 0000000..cda5457 --- /dev/null +++ b/web/src/app/api/newsletter/subscribe/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Newsletter subscribe proxy (Session S7, a1) — S25 rule: the browser hits + * the Next origin, never Express directly. Forwards to the backend, which + * forwards to the self-hosted Listmonk (double opt-in). + * + * Failure posture: any upstream problem degrades to the calm + * { ok: false, reason: 'not configured' } shape — the capture component + * renders "Signups open soon", never an error wall. + */ +export async function POST(req: NextRequest) { + let body: unknown = {}; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, error: 'Enter a valid email.' }, { status: 400 }); + } + try { + const upstream = await fetch(`${BACKEND_URL}/api/newsletter/subscribe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + const data = await upstream.json().catch(() => ({ ok: false, reason: 'not configured' })); + return NextResponse.json(data, { status: upstream.status === 400 ? 400 : 200 }); + } catch { + return NextResponse.json({ ok: false, reason: 'not configured' }, { status: 200 }); + } +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 942d841..c7fdb97 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -23,6 +23,8 @@ import Features from '@/components/Features'; import HowItWorks from '@/components/HowItWorks'; import Pricing from '@/components/Pricing'; import FAQ from '@/components/FAQ'; +// Session S7 (a1) — THE VYNDR REPORT capture (double opt-in via Listmonk). +import NewsletterCapture from '@/components/NewsletterCapture'; // Footer is mounted globally in the root layout (Session 34) — no per-page import. // Session 60 (6.1) — the 3–4s "LOADING THE SLATE" first paint was THIS @@ -91,6 +93,10 @@ export default function Home() { + {/* Session S7 (a1) — the daily wire, by email. Sits above the global footer. */} +
+ +
); } diff --git a/web/src/app/welcome/page.tsx b/web/src/app/welcome/page.tsx index 0d6c02c..9ab93ae 100644 --- a/web/src/app/welcome/page.tsx +++ b/web/src/app/welcome/page.tsx @@ -3,6 +3,10 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useEffect, useRef } from 'react'; +// Session S7 (a1) — THE VYNDR REPORT capture on the free-signup success path. +// This page (not the signup "done" card, which auto-redirects after 1.5s) is +// where a new free user actually lingers — the honest place for the ask. +import NewsletterCapture from '@/components/NewsletterCapture'; export default function WelcomePage() { const router = useRouter(); @@ -84,6 +88,11 @@ export default function WelcomePage() {

BUILT IN DETROIT

+ + {/* Session S7 (a1) — the daily wire, by email. Double opt-in via Listmonk. */} +
+ +
); } diff --git a/web/src/components/NewsletterCapture.tsx b/web/src/components/NewsletterCapture.tsx new file mode 100644 index 0000000..c7ab3df --- /dev/null +++ b/web/src/components/NewsletterCapture.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; + +/** + * THE VYNDR REPORT capture (Session S7, a1) — email field for the daily + * newsletter. Dark terminal surface, mono for data (the email IS data), + * VOICE v1.1 copy: deadpan, no exclamation points. + * + * States: + * idle → busy → sent ("Confirmation sent. Check your inbox.") + * soon (backend not configured → "Signups open soon.") + * error (bad email / transient upstream) + * + * Double opt-in is real: the backend asks Listmonk to send a confirmation; + * nothing lands on the list until the reader clicks it. The note under the + * field says exactly that. + */ +export default function NewsletterCapture({ compact = false }: { compact?: boolean }) { + const [email, setEmail] = useState(''); + const [website, setWebsite] = useState(''); // honeypot — humans never see it + const [state, setState] = useState<'idle' | 'busy' | 'sent' | 'soon' | 'error'>('idle'); + const [message, setMessage] = useState(''); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (state === 'busy') return; + setState('busy'); + setMessage(''); + try { + const res = await fetch('/api/newsletter/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email.trim(), website }), + }); + const data = await res.json().catch(() => ({ ok: false, reason: 'not configured' })); + if (data.ok) { + setState('sent'); + } else if (res.status === 400) { + setState('error'); + setMessage(data.error || 'Enter a valid email.'); + } else { + // 'not configured' or transient upstream — calm either way. + setState('soon'); + } + } catch { + setState('soon'); + } + }; + + if (state === 'sent') { + return ( +
+
+ THE VYNDR REPORT +
+

+ Confirmation sent. Check your inbox — nothing arrives until you click it. +

+
+ ); + } + + return ( +
+
+ THE VYNDR REPORT +
+

+ The slate, the signals, the settle. Daily. Free. +

+ + {state === 'soon' ? ( +

+ Signups open soon. +

+ ) : ( +
+ {/* Honeypot — visually hidden, tab-skipped. Bots fill it, humans don't. */} + setWebsite(e.target.value)} + tabIndex={-1} + autoComplete="off" + aria-hidden="true" + style={{ position: 'absolute', left: -9999, width: 1, height: 1, opacity: 0 }} + /> + setEmail(e.target.value)} + placeholder="you@wherever.com" + aria-label="Email for THE VYNDR REPORT" + className="mono" + style={{ + flex: '1 1 200px', + minWidth: 0, + background: 'var(--bg-2, transparent)', + border: '1px solid var(--border)', + borderRadius: 6, + padding: '10px 12px', + fontSize: 13, + color: 'var(--text-0, var(--text-primary))', + outline: 'none', + }} + /> + +
+ )} + + {state === 'error' && message && ( +

+ {message} +

+ )} + + {state !== 'soon' && ( +

+ Double opt-in — a confirmation email lands first; nothing sends until you click it. + Unsubscribe is one click, forever. 21+. +

+ )} +
+ ); +}