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:
@@ -19,6 +19,33 @@ Zero out-of-pocket; everything config-flip-ready but DISABLED/organic.
|
|||||||
migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run).
|
migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run).
|
||||||
- **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention
|
- **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention
|
||||||
(partner code == promotion code), TODO migration SQL.
|
(partner code == promotion code), TODO migration SQL.
|
||||||
|
## 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
|
## Current Phase
|
||||||
SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
|
SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum,
|
||||||
|
|||||||
@@ -826,6 +826,34 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
|||||||
- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees
|
- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees
|
||||||
and Turbopack REJECTS a symlink pointing outside the project root — use
|
and Turbopack REJECTS a symlink pointing outside the project root — use
|
||||||
`cp -al` (hardlink copy) from the main repo's web/node_modules.
|
`cp -al` (hardlink copy) from the main repo's web/node_modules.
|
||||||
|
## 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
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
|
|||||||
@@ -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=<postgres host>
|
||||||
|
LISTMONK_db__port=5432
|
||||||
|
LISTMONK_db__user=listmonk
|
||||||
|
LISTMONK_db__password=<generate>
|
||||||
|
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:<token>`.
|
||||||
|
|
||||||
|
## 4. Env vars for the VYNDR backend (Coolify → the Express app)
|
||||||
|
|
||||||
|
```
|
||||||
|
LISTMONK_URL=http://<listmonk container name>:9000 # same box — use the internal network, not the public URL
|
||||||
|
LISTMONK_USER=vyndr-api
|
||||||
|
LISTMONK_TOKEN=<the API token from step 3>
|
||||||
|
LISTMONK_LIST_ID=<numeric id from step 2>
|
||||||
|
```
|
||||||
|
|
||||||
|
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://<backend>/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.
|
||||||
@@ -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).
|
||||||
@@ -184,6 +184,9 @@ app.use('/api/books', bookComparisonRoutes);
|
|||||||
// generated from live data, degrading gracefully by data level.
|
// generated from live data, degrading gracefully by data level.
|
||||||
const contentRoutes = require('./routes/content');
|
const contentRoutes = require('./routes/content');
|
||||||
app.use('/api/content', contentRoutes);
|
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,
|
// Session 18 — internal ops endpoints (admin dashboard triggers,
|
||||||
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
|
// shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from
|
||||||
// the public surface; the Next.js admin route proxies through with
|
// the public surface; the Next.js admin route proxies through with
|
||||||
|
|||||||
@@ -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) => {
|
router.post('/outcomes/:sport', async (req, res) => {
|
||||||
const outcomes = require('../services/outcomeService');
|
const outcomes = require('../services/outcomeService');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import Features from '@/components/Features';
|
|||||||
import HowItWorks from '@/components/HowItWorks';
|
import HowItWorks from '@/components/HowItWorks';
|
||||||
import Pricing from '@/components/Pricing';
|
import Pricing from '@/components/Pricing';
|
||||||
import FAQ from '@/components/FAQ';
|
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.
|
// 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
|
// Session 60 (6.1) — the 3–4s "LOADING THE SLATE" first paint was THIS
|
||||||
@@ -91,6 +93,10 @@ export default function Home() {
|
|||||||
<HowItWorks />
|
<HowItWorks />
|
||||||
<Pricing />
|
<Pricing />
|
||||||
<FAQ />
|
<FAQ />
|
||||||
|
{/* Session S7 (a1) — the daily wire, by email. Sits above the global footer. */}
|
||||||
|
<div style={{ maxWidth: 640, margin: '0 auto', padding: '8px 16px 40px' }}>
|
||||||
|
<NewsletterCapture />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useEffect, useRef } from 'react';
|
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() {
|
export default function WelcomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -84,6 +88,11 @@ export default function WelcomePage() {
|
|||||||
</button>
|
</button>
|
||||||
<p className="lbl" style={{ color: 'var(--text-2)' }}>BUILT IN DETROIT</p>
|
<p className="lbl" style={{ color: 'var(--text-2)' }}>BUILT IN DETROIT</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session S7 (a1) — the daily wire, by email. Double opt-in via Listmonk. */}
|
||||||
|
<div style={{ marginTop: 32, width: '100%', maxWidth: 440, textAlign: 'left', zIndex: 1 }}>
|
||||||
|
<NewsletterCapture compact />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="surface"
|
||||||
|
style={{ padding: compact ? '16px 18px' : '22px 24px', border: '1px solid var(--border)', borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 6 }}>
|
||||||
|
THE VYNDR REPORT
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
|
||||||
|
Confirmation sent. Check your inbox — nothing arrives until you click it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="surface"
|
||||||
|
style={{ padding: compact ? '16px 18px' : '22px 24px', border: '1px solid var(--border)', borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.14em', color: 'var(--g-a)', marginBottom: 6 }}>
|
||||||
|
THE VYNDR REPORT
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: compact ? 13 : 14, color: 'var(--text-0, var(--text-primary))', margin: '0 0 12px' }}>
|
||||||
|
The slate, the signals, the settle. Daily. Free.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{state === 'soon' ? (
|
||||||
|
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
|
||||||
|
Signups open soon.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{/* Honeypot — visually hidden, tab-skipped. Bots fill it, humans don't. */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', left: -9999, width: 1, height: 1, opacity: 0 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={state === 'busy'}
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
background: 'var(--g-a, var(--grade-a))',
|
||||||
|
color: '#04140f',
|
||||||
|
border: '1px solid var(--g-a, var(--grade-a))',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.08em',
|
||||||
|
cursor: state === 'busy' ? 'wait' : 'pointer',
|
||||||
|
opacity: state === 'busy' ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state === 'busy' ? 'SENDING' : 'SUBSCRIBE'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === 'error' && message && (
|
||||||
|
<p className="mono" style={{ fontSize: 12, color: 'var(--miss, #ff8a8a)', margin: '8px 0 0' }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state !== 'soon' && (
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-tertiary, var(--text-secondary))', margin: '10px 0 0', lineHeight: 1.5 }}>
|
||||||
|
Double opt-in — a confirmation email lands first; nothing sends until you click it.
|
||||||
|
Unsubscribe is one click, forever. 21+.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user