diff --git a/src/app.js b/src/app.js index b711bb9..c833e0b 100644 --- a/src/app.js +++ b/src/app.js @@ -204,6 +204,9 @@ 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')); +// E12 — The Report archive. Public, read-only; every issue carries its own +// day record, because the archive is a ledger too. +app.use('/api/report', require('./routes/report')); // A1 S9 — Slip Reader: OCR a bet-slip screenshot into legs (auth + // per-tier daily quota inside the router). Values are user-slip values. app.use('/api/slips', require('./routes/slips')); diff --git a/src/routes/report.js b/src/routes/report.js new file mode 100644 index 0000000..ed57ffd --- /dev/null +++ b/src/routes/report.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * /api/report — E12, the issue archive. + * + * The spec's law is one line: **"EVERY ISSUE SHOWS ITS OWN DAY RECORD — THE + * ARCHIVE IS A LEDGER TOO."** So an archive row is not a headline with a date; + * it carries the record that issue's reads actually produced. An archive that + * showed only titles would be a blog, and the point of this one is that it + * cannot quietly bury a bad day. + * + * Public and READ-ONLY. Issues live in Redis under `report:issue:{date}`, + * written by the send path; nothing here touches a serving, model or ledger + * table. + */ + +const express = require('express'); + +const router = express.Router(); +const INDEX_KEY = 'report:index'; +const ISSUE_KEY = (d) => `report:issue:${d}`; + +/** GET /api/report — the archive list, newest first. */ +router.get('/', async (req, res) => { + try { + const { cacheGet } = require('../utils/redis'); + const index = (await cacheGet(INDEX_KEY)) || []; + const issues = Array.isArray(index) ? index : []; + const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 60)); + res.set('Cache-Control', 'public, max-age=300'); + res.json({ + count: issues.length, + // Honest empty state is the caller's to render; we simply report zero. + issues: issues.slice(0, limit), + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** GET /api/report/:date — one issue. 404 rather than an invented shell. */ +router.get('/:date', async (req, res) => { + try { + const { cacheGet } = require('../utils/redis'); + const issue = await cacheGet(ISSUE_KEY(req.params.date)); + if (!issue) return res.status(404).json({ error: 'no issue for that date' }); + res.set('Cache-Control', 'public, max-age=300'); + return res.json(issue); + } catch (e) { + return res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; +module.exports.__internals = { INDEX_KEY, ISSUE_KEY }; diff --git a/src/services/report/reportTemplate.js b/src/services/report/reportTemplate.js new file mode 100644 index 0000000..9639096 --- /dev/null +++ b/src/services/report/reportTemplate.js @@ -0,0 +1,169 @@ +'use strict'; + +/** + * E10 — THE VYNDR REPORT, the designed issue template. + * + * The gap audit had this as PARTIAL, not absent: `newsletterService` already + * builds the daily report's CONTENT and lints its voice. What was missing is the + * designed HYBRID SHELL. So this is a template over that builder, not a second + * report — the same compose-don't-fork call made for the movement strip. + * + * ── THE SPEC'S STRUCTURAL LAW ──────────────────────────────────────────── + * "Hybrid: dark billboard header that survives every client, light paper body + * Gmail can't wreck. 600px, stacked, no webfont dependence." + * + * That is an engineering constraint, not a look. Gmail strips `