From b4fa5d7be8cb4c62722f48572de7bbbeba1a3a64 Mon Sep 17 00:00:00 2001 From: Keysat Date: Mon, 15 Jun 2026 19:50:48 -0500 Subject: [PATCH] Add opt-in Daily Digest (daily email of last 24h of library recaps) Multi-mode, off by default. Each new recap is synthesized into a 1-2 paragraph overview via the relay (operator-absorbed) and cached onto the session JSON; a daily 08:00 scan emails opted-in users their fresh recaps, deduped by a per-user watermark that never skips a failed or over-cap recap. One-click tokenized unsubscribe; settings-modal toggle; admin test trigger. Bumps to 0.2.158. --- AGENTS.md | 4 +- docs/daily-digest-plan.md | 76 +++++- public/index.html | 72 ++++++ server/account-routes.js | 56 ++++ server/admin-routes.js | 64 +++++ server/daily-digest.js | 426 +++++++++++++++++++++++++++++++ server/db.js | 46 +++- server/email-template.js | 104 ++++++++ server/history.js | 37 +++ server/index.js | 9 + server/tenant-auth.js | 1 + server/test/daily-digest.test.js | 248 ++++++++++++++++++ startos/versions/index.ts | 5 +- startos/versions/v0.2.158.ts | 13 + 14 files changed, 1144 insertions(+), 17 deletions(-) create mode 100644 server/daily-digest.js create mode 100644 server/test/daily-digest.test.js create mode 100644 startos/versions/v0.2.158.ts diff --git a/AGENTS.md b/AGENTS.md index bca15ef..6cd20c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,13 +127,13 @@ unsure whether a change is contract-affecting, assume it is and check. ## Current state -**Live on the operator's StartOS box** — app **0.2.157** + relay **0.2.124**. Tests: `cd server && npm test` → **119 pass**. +**Live on the operator's StartOS box** — app **0.2.157** + relay **0.2.124** (0.2.158 built + committed, install in progress). Tests: `cd server && npm test` → **142 pass**. **Done & live:** self-serve Pro/Max purchase (Bitcoin inline-Lightning + Zaprite card, prepaid, relay owns tier/expiry), core-decoupling, per-tenant subscriptions, and expiry-reminder emails (`POST /api/admin/reminders/run {test_email}`). Plans in `docs/*-plan.md`. **Shipped this session (committed + pushed + verified live):** **0.2.156** — iOS sign-in "network error" flake; both sign-in paths now retry 3× with growing backoff (`91af0b7`). **0.2.157** — mobile/UX cluster: YT-minimize black-frame, podcast-audio + scroll loss on background re-render, redundant loading box, and a best-effort iOS scroll tweak (`693bb98`). Mechanics now captured as conventions above. -**In progress — Daily Digest** (`docs/daily-digest-plan.md`, **proposed, awaiting go-ahead**): opt-in (off by default) daily email of the last 24h of library recaps, each a 1–2 paragraph overview synthesized from the recap's stored topic summaries; clones the `subscription-reminders.js` scan pattern. Next: build **phase 1** (schema `users.digest_enabled`/`last_digest_at` + opt-in toggle + settings UI) — but **first resolve open Q4: does `/relay/analyze` fit the synthesis call, or is a new relay capability needed?** (cross-repo: would touch `../recap-relay`). The other 3 open Qs (synthesis-cost owner, send hour, single-mode) have defaults in the plan. +**Daily Digest — FEATURE-COMPLETE; 0.2.158 built + committed, install in progress** (`docs/daily-digest-plan.md`). Opt-in (off by default) once-a-day email of a user's last ~24h of library recaps, each a 1–2 paragraph overview synthesized from the recap's stored topic summaries; clones the `subscription-reminders.js` scan pattern. **All 5 phases built:** schema (`users.digest_enabled`/`last_digest_at`/`digest_unsub_token` + `migrateUserDigestPrefs`); `GET`/`POST /api/account/digest` (opt-in stamps the watermark to now) + settings-modal toggle; `server/daily-digest.js` (synthesis via `/relay/analyze`, **operator-absorbed** via operator install identity, `digestOverview` cache; `selectDigestEpisodes` watermark/cap/overflow; `runDigestScan` acts at `SEND_HOUR=8`, `MIN_RESEND_MS=20h`, advances watermark only on send, never throws; `startDigestScheduler`; public `GET /api/digest/unsubscribe?token=`); `renderDigestEmail`; `listScopeSessions`; `POST /api/admin/digest/run {test_email}`. Wired in `index.js` (multi) + `tenant-auth.js` public path. **19 digest tests, full suite 138 pass.** Verified on a real multi-mode boot (migrations apply, scheduler starts, unsubscribe 400/404/200 flips the flag end-to-end). **Q4** — `/relay/analyze` fits as-is, no relay change. **Q1** — operator-absorbed, zero operator action. **Pending: on-box smoke test** (relay synthesis + SMTP only run on the box) — `POST /api/admin/digest/run {test_email}` to eyeball the render, then opt in + add a recap + force a scan. **No version bump yet.** (Aside: relay `AGENTS.md:78` mis-describes `/relay/analyze` as `{transcript}→topic sections JSON` — stale; flagged for `../recap-relay` in the inbox.) **Pending operator actions:** 1. **Verify the mobile can't-scroll-to-top fix on the iPad** — UNVERIFIED in 0.2.157 (iOS-layout-specific, not reproducible off-device); send a screen recording if it persists. Inbox item kept open + annotated. diff --git a/docs/daily-digest-plan.md b/docs/daily-digest-plan.md index a86fbb4..dcf2539 100644 --- a/docs/daily-digest-plan.md +++ b/docs/daily-digest-plan.md @@ -74,20 +74,72 @@ the user's credits. Confirm. ## Open questions (defaults chosen; confirm or adjust) -1. **Synthesis cost owner** — operator-absorbed (default) vs user credits? +1. **Synthesis cost owner** — ~~operator-absorbed (default) vs user credits?~~ + **RESOLVED 2026-06-15: operator-absorbed, zero operator action.** The synthesis + provider is built with `resolveProviderOpts("relay", { req: null })` → the operator's + install identity, the *same* relay credit pool free signed-in users' summaries already + draw from (`providers/index.js` `pickRelayIdentity`). No comped system user-id needed. + Flipping to user-billing later = pass the recipient's cloud identity at the marked line + in `daily-digest.js` `buildSynthesisProvider()`. 2. **Send hour** — 08:00 server time (default)? 3. **Single-mode operator digest** — defer to a follow-on (default: multi-mode only v1)? -4. **Relay contract** — does an existing relay endpoint (`/relay/analyze`) fit the - "summarize these topic summaries into 2 paragraphs" call, or is a small new relay - capability/prompt-mode needed? If new, update `../recap-relay` + both repos' - `AGENTS.md`/`ROADMAP.md` per the cross-repo rule. **Resolve before phase 2.** +4. **Relay contract** — ~~does an existing relay endpoint (`/relay/analyze`) fit~~ + **RESOLVED 2026-06-15: `/relay/analyze` fits as-is, no new relay capability.** The + route (`recap-relay/server/routes/analyze.js`) takes a free-form `{ prompt: string }` + and returns `{ result: { text } }`; the client already wraps it as + `relay.js` `analyzeText({ prompt }) → result.text`. "Topic sections JSON" is only what + today's `chunked-analyze.js` caller asks for in *its* prompt — the endpoint is generic. + Synthesis = build a "summarize these summaries into 1–2 paragraphs" prompt, read + `result.text`. **No cross-repo change.** (Aside: relay `AGENTS.md:78` still describes + this endpoint as `{ transcript, … } → topic sections JSON` — stale; flag for that repo.) + Billing: each standalone analyze charges 1 credit on the call's credit key unless it + shares an `X-Recap-Job-Id` — that's the Q1 (cost-owner) mechanism, decided at phase 2. ## Build phases -1. Schema + opt-in toggle (migration, account endpoint, settings UI). -2. Synthesis + cache (relay call + write-back + operator-string scrub). Resolve the - relay-contract question first. -3. Email template + scan loop + scheduler + watermark dedup + overflow cap. -4. Operator test trigger. -5. Tests — pure-function coverage (episode selection vs watermark, cap/overflow, empty - → skip), in the `subscription-reminders` test style. +1. **BUILT 2026-06-15.** Schema + opt-in toggle. `db.js`: `users.digest_enabled` + (default 0) + `users.last_digest_at` (ms, nullable) via SCHEMA_SQL + + `migrateUserDigestPrefs`. `account-routes.js`: `GET`/`POST /api/account/digest` + (enabling stamps `last_digest_at = now` so the first send isn't a backlog dump). + `public/index.html`: settings-modal toggle (`renderDigestBlock` + `loadMyDigest` / + `setDigestEnabled`, optimistic with revert). +2. **BUILT 2026-06-15.** Synthesis + cache → `server/daily-digest.js`: + `buildOverviewPrompt` (pure), `scrubOperatorStrings` (conservative backstop — infra + proper nouns + LAN/private hosts; dropped CUDA to avoid mangling legit tech content), + `synthesizeEpisodeOverview` (relay `analyzeText`, operator-absorbed identity, stable + per-episode jobId), `getOrCreateEpisodeOverview` (`digestOverview` cache + best-effort + `patchSession` write-back). NOT wired into a scheduler yet — dormant until phase 3. + Tests: `test/daily-digest.test.js` (12, pass). Note: chunks carry a `summary` text per + topic (not bullets — the Data section's "bullet summaries" wording was loose). +3. **BUILT 2026-06-15.** Email + scan + scheduler + dedup + overflow cap. + `email-template.js` `renderDigestEmail` (minimal inline style, per-episode title→source + link + overview, overflow line, one-click unsubscribe). `daily-digest.js`: + `selectDigestEpisodes` (pure: watermark filter + cap + overflow), `runDigestScan` + (hourly tick, acts at `SEND_HOUR=8`; per-user `MIN_RESEND_MS=20h` + watermark dedup; + skips empty; advances watermark only on successful send; never throws), + `startDigestScheduler`, `setupDigestRoutes` (public `GET /api/digest/unsubscribe?token=`). + `history.js` `listScopeSessions`. `db.js` adds `users.digest_unsub_token` (minted lazily + on first send). Wired in `index.js` (multi-mode) + `tenant-auth.js` public path. +4. **BUILT 2026-06-15.** `POST /api/admin/digest/run` — `{test_email}` sends a sample + render; bare body forces a real scan now (bypasses the hour gate, not the resend gate). + Mirrors `/api/admin/reminders/run`. +5. **DONE.** `test/daily-digest.test.js` — 19 tests (prompt, scrub, synth/cache, + `selectDigestEpisodes` watermark/cap/overflow/empty, `scopeForUser`, email render). + Full suite **138 pass**. Verified on a real multi-mode boot: migrations apply, scheduler + starts, and the unsubscribe route (400/404/200 + flips `digest_enabled`) works end-to-end. + +## Status: feature-complete, awaiting on-box smoke test + +Built end-to-end but **not yet installed** (no version bump). The relay synthesis call and +SMTP send can only be exercised on the operator's box. Operator smoke test: +`POST /api/admin/digest/run {test_email}` to eyeball the render; then opt in, add a recap, +and force a scan (or wait for 08:00) to see a real synthesized digest. + +**Fresh-eyes review applied (2026-06-15).** Three correctness fixes after a reviewer pass: +(1) the watermark now advances to the newest *sent* recap but never past a failed/deferred +one (`nextDigestWatermark`) — the old `now` stamp silently dropped both synthesis-failures +and over-cap overflow recaps forever; (2) `force` no longer bypasses the in-progress lock, +so an operator force-run during the scheduled tick can't double-send; (3) `idx_users_unsub_token` +is created in the migration, not `SCHEMA_SQL` (the latter runs before the column exists on +upgraded DBs → would crash boot). Existing-DB upgrade verified on a realistic pre-digest +schema. Also added an index on the unauthenticated token lookup + a null-scope guard. diff --git a/public/index.html b/public/index.html index 01e79e5..0f5459f 100644 --- a/public/index.html +++ b/public/index.html @@ -2362,6 +2362,9 @@ // User's own active sessions (lite-settings tenant view). Loaded // on demand when the Settings modal opens. mySessions: { rows: null, loading: false, error: null }, + // Daily Digest opt-in (lite-settings tenant view). enabled is null + // until loaded from /api/account/digest when the modal opens. + digest: { enabled: null, loading: false, saving: false }, // "Take Recap home" — fetches the tenant's raw license key on // demand. We don't load this in /api/account/whoami because the // key is a bearer credential we'd rather not pass through the @@ -7343,6 +7346,7 @@ ${state.account?.user ? renderClaimPurchaseBlock() : ""} ${state.account?.user ? renderPasswordBlock() : ""} ${state.account?.user ? renderMySessionsBlock() : ""} + ${state.account?.user ? renderDigestBlock() : ""} ${renderLibraryTransfer()} ${state.account?.user ? renderTenantDangerZone() : ""} @@ -7874,6 +7878,33 @@ `; } + // Daily Digest opt-in toggle. Off by default; a single switch that + // POSTs /api/account/digest. enabled is null until loaded — show a + // muted "Loading…" rather than a misleading unchecked box during the + // round-trip so the user never sees the wrong initial state. + function renderDigestBlock() { + const d = state.digest || {}; + const checkboxAttrs = + d.enabled === null || d.loading || d.saving ? "disabled" : ""; + return ` + +
+ +
+ `; + } + // Danger Zone — sits at the very bottom of the tenant lite-settings // modal. One action for now (Delete Account); future destructive // self-actions land here. Visually muted by default to avoid being @@ -10264,6 +10295,7 @@ loadAdminActivity(state.ops.activityHours || 24); } else if (state.account?.user) { loadMySessions(); + loadMyDigest(); } } render(); @@ -12012,6 +12044,46 @@ } } + async function loadMyDigest() { + state.digest.loading = true; + render(); + try { + const res = await fetch(`${API_BASE}/api/account/digest`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + state.digest.enabled = !!data.enabled; + } catch (e) { + // Leave enabled null; the block keeps showing "Loading…" rather + // than asserting a state we couldn't confirm. + } finally { + state.digest.loading = false; + render(); + } + } + + async function setDigestEnabled(enabled) { + const prev = state.digest.enabled; + // Optimistic flip so the switch responds instantly; revert on error. + state.digest.enabled = enabled; + state.digest.saving = true; + render(); + try { + const res = await fetch(`${API_BASE}/api/account/digest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + showToast(enabled ? "Daily digest on" : "Daily digest off", "✓"); + } catch (e) { + state.digest.enabled = prev; + showToast("Couldn't save that — try again", "!"); + } finally { + state.digest.saving = false; + render(); + } + } + async function revokeMySession(sessionId) { try { const res = await fetch( diff --git a/server/account-routes.js b/server/account-routes.js index 5c5e3ab..6f6a511 100644 --- a/server/account-routes.js +++ b/server/account-routes.js @@ -279,4 +279,60 @@ export function setupAccountRoutes(app) { } }, ); + + // ── Daily Digest opt-in ──────────────────────────────────────────── + // Opt-in (off by default) daily email of the last ~24h of library + // recaps. The relay-owned subscription tier is unrelated — any + // signed-in user may toggle this. GET reads current state; POST + // {enabled:bool} flips it. Enabling stamps last_digest_at to "now" + // so the first digest covers only recaps added AFTER opt-in, never + // the user's whole backlog (the scan picks createdAt > watermark). + app.get("/api/account/digest", requireUser, (req, res) => { + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "auth_required" }); + } + try { + const row = getDb() + .prepare("SELECT digest_enabled, last_digest_at FROM users WHERE id = ?") + .get(req.user.id); + res.json({ + enabled: !!row?.digest_enabled, + last_digest_at: row?.last_digest_at ?? null, + }); + } catch (err) { + console.error("[account] digest read failed:", err); + res.status(500).json({ error: "internal_error" }); + } + }); + + app.post("/api/account/digest", requireUser, (req, res) => { + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "auth_required" }); + } + const enabled = req.body?.enabled; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "enabled_must_be_boolean" }); + } + try { + if (enabled) { + // Start the watermark at opt-in so the first send isn't a backlog dump. + getDb() + .prepare( + "UPDATE users SET digest_enabled = 1, last_digest_at = ? WHERE id = ?", + ) + .run(Date.now(), req.user.id); + } else { + getDb() + .prepare("UPDATE users SET digest_enabled = 0 WHERE id = ?") + .run(req.user.id); + } + console.log( + `[account] digest ${enabled ? "enabled" : "disabled"} for user ${req.user.id}`, + ); + res.json({ ok: true, enabled }); + } catch (err) { + console.error("[account] digest toggle failed:", err); + res.status(500).json({ error: "internal_error" }); + } + }); } diff --git a/server/admin-routes.js b/server/admin-routes.js index 405f6cf..c6a54b6 100644 --- a/server/admin-routes.js +++ b/server/admin-routes.js @@ -84,6 +84,70 @@ export function setupAdminRoutes(app) { } }); + // Daily-digest test trigger. With {test_email}, sends a sample digest + // to that address so the operator can eyeball the rendering without + // opted-in users or waiting for the send hour. Without it, forces a + // real scan now (bypassing the 08:00 gate, NOT the per-user resend gate). + app.post("/api/admin/digest/run", requireOperator, async (req, res) => { + try { + const testEmail = + typeof req.body?.test_email === "string" ? req.body.test_email.trim() : ""; + if (testEmail) { + const { isSmtpReady, sendMail } = await import("./smtp.js"); + if (!isSmtpReady()) { + return res.status(503).json({ + error: "smtp_not_ready", + message: "Configure StartOS System SMTP first.", + }); + } + const { renderDigestEmail } = await import("./email-template.js"); + const { getConfigSnapshot } = await import("./config.js"); + const snap = await getConfigSnapshot(); + const publicUrl = (snap.recap_public_url || "") + .trim() + .replace(/\/$/, ""); + const msg = renderDigestEmail({ + brandName: "Recaps", + episodes: [ + { + title: "Sample podcast episode", + type: "podcast", + url: "https://example.com/episode", + overview: + "This is a sample overview paragraph so you can see how a digest entry renders. The real thing is synthesized from each recap's stored topic summaries.", + }, + { + title: "Sample YouTube video", + type: "youtube", + url: "https://youtube.com/watch?v=example", + overview: + "A second sample entry, showing how multiple recaps stack in one email.", + }, + ], + overflowCount: 0, + manageUrl: `${publicUrl}/`, + unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=sample`, + }); + await sendMail({ + to: testEmail, + subject: msg.subject, + text: msg.text, + html: msg.html, + }); + return res.json({ ok: true, test_email_sent_to: testEmail }); + } + const { runDigestScan } = await import("./daily-digest.js"); + const result = await runDigestScan({ force: true }); + res.json({ ok: true, ...result }); + } catch (err) { + console.error("[admin] digest run failed:", err?.message || err); + res.status(500).json({ + error: "digest_run_failed", + message: err?.message || String(err), + }); + } + }); + // ── List all tenants ─────────────────────────────────────────────────── app.get("/api/admin/tenants", requireOperator, (req, res) => { try { diff --git a/server/daily-digest.js b/server/daily-digest.js new file mode 100644 index 0000000..eac3953 --- /dev/null +++ b/server/daily-digest.js @@ -0,0 +1,426 @@ +// Daily Digest — per-episode overview synthesis (multi-mode / cloud). +// +// Phase 2 of the Daily Digest feature: turn a saved recap's stored topic +// summaries into a 1–2 paragraph overview via the relay LLM, then cache +// the result back onto the session JSON (`digestOverview`) so it's +// generated at most once per episode. The daily scan + email (phase 3) +// will call getOrCreateEpisodeOverview(); no scheduler lives here yet. +// +// Cost ownership (Q1 = operator-absorbed): the synthesis call uses the +// OPERATOR's relay identity — the same credit pool that free signed-in +// users' summaries already draw from (resolveProviderOpts with req=null +// → operator install identity). A retention email shouldn't silently +// drain the recipient's quota for recaps they already made. To bill the +// recipient instead, build the provider with their cloud identity at the +// one marked line below. + +import { randomBytes } from "crypto"; +import { getProvider, resolveProviderOpts } from "./providers/index.js"; +import { patchSession, loadSession, listScopeSessions } from "./history.js"; +import { getDb } from "./db.js"; +import { sendMail, isSmtpReady } from "./smtp.js"; +import { renderDigestEmail } from "./email-template.js"; +import { getConfigSnapshot } from "./config.js"; + +// Operator-internal vocabulary the sibling relay could surface in model +// output (backend / hardware names, LAN hosts). Scrubbed before any +// digest text reaches a cloud user — the same error-boundary rule the +// rest of the app follows. This is a backstop: the synthesis input is +// the recap's own (already user-facing) topic summaries, so a leak here +// is unlikely. Kept conservative to avoid mangling legitimate prose — +// only unambiguous infra tokens and private/LAN hosts, never common +// words or public data. +const OPERATOR_TERMS = [ + /\bspark[\s-]?control\b/gi, + /\bparakeet\b/gi, + /\bsortformer\b/gi, + /\btitanet\b/gi, + /\bvllm\b/gi, +]; +const LAN_HOST_RE = /\bhttps?:\/\/[^\s)]*\.local\b[^\s)]*/gi; +const PRIVATE_IP_RE = + /\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b/g; + +export function scrubOperatorStrings(text) { + if (!text) return ""; + let out = String(text); + for (const re of OPERATOR_TERMS) out = out.replace(re, ""); + out = out.replace(LAN_HOST_RE, ""); + out = out.replace(PRIVATE_IP_RE, ""); + // Tidy whitespace / orphaned punctuation the removals may have left. + return out + .replace(/[ \t]{2,}/g, " ") + .replace(/\s+([.,;:])/g, "$1") + .trim(); +} + +// Build the LLM prompt from a saved recap record. Pure — exported for +// testing. Uses each topic's title + summary (the chunk shape is +// { title, summary, … }); a topic with no summary still contributes its +// title so the overview knows it was covered. +export function buildOverviewPrompt(record) { + const title = (record?.title || "Untitled").trim(); + const type = + record?.type === "podcast" + ? "podcast episode" + : record?.type === "youtube" + ? "video" + : "recording"; + const topics = Array.isArray(record?.chunks) ? record.chunks : []; + const topicBlock = topics + .map((c, i) => { + const t = (c?.title || `Topic ${i + 1}`).trim(); + const s = (c?.summary || "").trim(); + return s ? `- ${t}: ${s}` : `- ${t}`; + }) + .join("\n"); + return [ + `Below are the per-topic summaries of a ${type} titled "${title}".`, + "", + "Write a tight 1–2 paragraph overview (about 100–150 words) that captures " + + "the main throughline and the few most important takeaways, as if briefing " + + "a busy reader who hasn't seen it. Do not invent anything beyond the " + + "summaries below, use no headings or bullet points, and write in plain prose.", + "", + "Topic summaries:", + topicBlock, + ].join("\n"); +} + +// Operator-identity relay provider for synthesis (operator-absorbed). +// Throws if the relay isn't configured (no install id / base URL) — the +// caller treats that as "skip this episode", not a fatal error. +function buildSynthesisProvider() { + // req=null → operator install identity. Swap in a per-recipient cloud + // identity here to bill the user instead of the operator. + const opts = resolveProviderOpts("relay", { req: null }); + return getProvider("relay", opts); +} + +// Synthesize (no cache) — call the relay, scrub, return the overview +// text. Throws on no-topics or an empty model result. `provider` is +// injectable for testing; defaults to the operator-identity relay. +export async function synthesizeEpisodeOverview(record, { provider } = {}) { + const topics = Array.isArray(record?.chunks) ? record.chunks : []; + if (topics.length === 0) { + throw new Error("no topic summaries to synthesize"); + } + const p = provider || buildSynthesisProvider(); + const prompt = buildOverviewPrompt(record); + const result = await p.analyzeText({ + prompt, + retries: 1, + // Stable per-episode billing key: a retry within the relay's job + // window reuses the same credit rather than charging twice. + jobId: record?.id ? `digest-${record.id}` : undefined, + }); + const text = scrubOperatorStrings(result?.text || ""); + if (!text) throw new Error("empty synthesis result"); + return text; +} + +// Get-or-generate the cached overview. Returns { overview, cached }. On a +// cache miss it synthesizes and (unless save:false) writes the result +// back onto the session JSON so the next caller is a cache hit. The +// write-back is best-effort — a failed patch just means we re-synthesize +// next time, never a user-visible error. +export async function getOrCreateEpisodeOverview({ + scope, + id, + record, + provider, + save = true, +}) { + const cached = (record?.digestOverview || "").trim(); + if (cached) return { overview: cached, cached: true }; + const overview = await synthesizeEpisodeOverview(record, { provider }); + if (save && scope && id) { + try { + await patchSession(scope, id, { digestOverview: overview }); + } catch { + // best-effort cache; ignore + } + } + return { overview, cached: false }; +} + +// ── Daily scan + scheduler (mirrors subscription-reminders.js) ────────── + +const SEND_HOUR = 8; // 08:00 server-local — when the daily scan acts +const SCAN_INTERVAL_MS = 60 * 60 * 1000; // tick hourly; act only at SEND_HOUR +const BOOT_DELAY_MS = 2 * 60 * 1000; +// A user gets at most one digest per ~day even if the loop ticks more +// than once inside the send hour or they add content right after a send. +const MIN_RESEND_MS = 20 * 60 * 60 * 1000; +const MAX_EPISODES = 10; // cap per email; the rest become an overflow count + +let scanning = false; +let scheduled = false; + +// Which library scope a user's recaps live under. Mirrors +// history.js scopeForRequest: the multi-mode admin keeps the "owner" +// scope; everyone else is scoped by their user id. Pure — exported for +// testing. +export function scopeForUser(user) { + return user?.is_admin ? "owner" : user?.id; +} + +// Pick the recaps created after the watermark, oldest first, capped. +// Pure — exported for testing. Returns { episodes, overflow, total }. +export function selectDigestEpisodes(sessions, watermarkMs, cap = MAX_EPISODES) { + const since = typeof watermarkMs === "number" ? watermarkMs : 0; + const fresh = (sessions || []) + .filter((s) => { + const t = new Date(s?.createdAt).getTime(); + return Number.isFinite(t) && t > since; + }) + .sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + return { + episodes: fresh.slice(0, cap), + overflow: Math.max(0, fresh.length - cap), + total: fresh.length, + }; +} + +function maskEmail(email) { + return String(email).replace(/^(.).*(@.*)$/, "$1***$2"); +} + +// Mint (and persist) a user's unsubscribe token if they don't have one +// yet. Returns the token. Stable per user — re-enabling reuses it. +function ensureUnsubToken(db, user) { + if (user.digest_unsub_token) return user.digest_unsub_token; + const token = randomBytes(32).toString("base64url"); + db.prepare("UPDATE users SET digest_unsub_token = ? WHERE id = ?").run( + token, + user.id, + ); + return token; +} + +// Build one user's digest: synthesize an overview per selected episode +// (operator-absorbed, cached). Returns { built, failed } where built are +// the episode payloads ready for the template (each carrying its source +// createdAt) and failed is the createdAt list of episodes that errored — +// the caller uses both to set a watermark that never skips a failure. +async function buildUserEpisodes(scope, selected) { + const built = []; + const failed = []; + for (const ep of selected) { + try { + const record = await loadSession(scope, ep.id); + if (!record) { + failed.push(ep.createdAt); + continue; + } + const { overview } = await getOrCreateEpisodeOverview({ + scope, + id: ep.id, + record, + }); + built.push({ + title: ep.title, + type: ep.type, + url: ep.url, + overview, + createdAt: ep.createdAt, + }); + } catch (err) { + failed.push(ep.createdAt); + console.warn( + `[digest] synthesis failed for ${scope}/${ep.id}: ${err?.message || err}`, + ); + } + } + return { built, failed }; +} + +// The watermark to stamp after a send. Advances to the newest +// successfully-sent recap, but never past the oldest one that FAILED (so +// the next scan retries gaps) and never past un-synthesized overflow +// recaps (their createdAt is newer than any sent one, so they're picked +// up next scan too). Pure — exported for testing. Returns null when +// nothing was sent (caller should not advance). createdAt inputs are ISO +// strings; output is ms epoch. +export function nextDigestWatermark(sentCreatedAts, failedCreatedAts) { + const toMs = (x) => new Date(x).getTime(); + const sent = (sentCreatedAts || []).map(toMs).filter(Number.isFinite); + if (sent.length === 0) return null; + const failed = (failedCreatedAts || []).map(toMs).filter(Number.isFinite); + const newestSent = Math.max(...sent); + const oldestFailed = failed.length ? Math.min(...failed) : Infinity; + return Math.min(newestSent, oldestFailed - 1); +} + +// One scan pass. Self-gating, deduped, NEVER throws — returns a small +// summary so the scheduler stays alive. `force` bypasses the send-hour +// gate (used by the operator test trigger), not the per-user resend gate. +export async function runDigestScan({ force = false } = {}) { + // `force` bypasses the send-hour gate (operator test trigger), NOT the + // in-progress lock — a forced run alongside the scheduled tick would + // otherwise double-send to every opted-in user. + if (scanning) return { skipped: "already_running" }; + scanning = true; + try { + const now = Date.now(); + if (!force && new Date(now).getHours() !== SEND_HOUR) { + return { skipped: "off_hour" }; + } + if (!isSmtpReady()) return { skipped: "smtp_not_ready" }; + const snap = await getConfigSnapshot(); + const publicUrl = (snap.recap_public_url || "").trim().replace(/\/$/, ""); + if (!publicUrl) return { skipped: "public_url_not_set" }; + + const db = getDb(); + const users = db + .prepare( + "SELECT id, email, is_admin, last_digest_at, digest_unsub_token FROM users WHERE digest_enabled = 1", + ) + .all(); + + let sent = 0; + let skipped = 0; + for (const user of users) { + try { + const email = (user.email || "").trim(); + if (!email) { + skipped++; + continue; + } + // Defensive: a row with no watermark (set via SQL, not the opt-in + // endpoint) would dump the whole backlog — start the clock now + // and pick up new recaps next scan instead. + if (typeof user.last_digest_at !== "number") { + db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run( + now, + user.id, + ); + skipped++; + continue; + } + if (now - user.last_digest_at < MIN_RESEND_MS) { + skipped++; + continue; + } + const scope = scopeForUser(user); + if (!scope) { + // No usable id (shouldn't happen for a real row) — skip rather + // than read an "undefined" scope dir. + skipped++; + continue; + } + const sessions = await listScopeSessions(scope); + const { episodes: selected, overflow } = selectDigestEpisodes( + sessions, + user.last_digest_at, + MAX_EPISODES, + ); + if (selected.length === 0) { + skipped++; // nothing new — skip the email, leave the watermark + continue; + } + const { built, failed } = await buildUserEpisodes(scope, selected); + if (built.length === 0) { + // Synthesis failed for all of them — don't advance the + // watermark, so the next scan retries the same recaps. + skipped++; + continue; + } + const token = ensureUnsubToken(db, user); + const message = renderDigestEmail({ + brandName: "Recaps", + episodes: built, + overflowCount: overflow, + manageUrl: `${publicUrl}/`, + unsubscribeUrl: `${publicUrl}/api/digest/unsubscribe?token=${encodeURIComponent(token)}`, + }); + await sendMail({ + to: email, + subject: message.subject, + text: message.text, + html: message.html, + }); + // Advance the watermark only after a successful send — to the + // newest sent recap, but never past a failed or deferred one, so + // the next scan retries gaps instead of skipping them. + const watermark = nextDigestWatermark( + built.map((e) => e.createdAt), + failed, + ); + db.prepare("UPDATE users SET last_digest_at = ? WHERE id = ?").run( + watermark ?? now, + user.id, + ); + sent++; + console.log( + `[digest] sent to ${maskEmail(email)} (${episodes.length} recap${episodes.length === 1 ? "" : "s"}${overflow ? `, +${overflow} more` : ""})`, + ); + } catch (err) { + console.warn( + `[digest] user ${user.id} failed: ${err?.message || err}`, + ); + skipped++; + } + } + if (sent) { + console.log(`[digest] scan complete: ${sent} sent, ${skipped} skipped`); + } + return { sent, skipped }; + } catch (err) { + console.warn(`[digest] scan error: ${err?.message || err}`); + return { skipped: "error", error: err?.message || String(err) }; + } finally { + scanning = false; + } +} + +// Start the hourly scan loop. Idempotent; self-gates inside the scan, so +// it's safe to call whenever multi mode boots. +export function startDigestScheduler() { + if (scheduled) return; + scheduled = true; + setTimeout(() => { + runDigestScan().catch(() => {}); + }, BOOT_DELAY_MS); + setInterval(() => { + runDigestScan().catch(() => {}); + }, SCAN_INTERVAL_MS); + console.log("[digest] daily-digest scheduler started"); +} + +// One-click unsubscribe — a public GET (no session) keyed by the per-user +// token in the email. Mounted in index.js (multi mode) and whitelisted in +// tenant-auth's public paths. Flips digest_enabled off; the in-app toggle +// can turn it back on. +export function setupDigestRoutes(app) { + app.get("/api/digest/unsubscribe", (req, res) => { + const token = String(req.query?.token || "").trim(); + const page = (msg) => + `
${msg}
`; + if (!token) { + return res.status(400).send(page("Invalid unsubscribe link.")); + } + try { + const r = getDb() + .prepare( + "UPDATE users SET digest_enabled = 0 WHERE digest_unsub_token = ?", + ) + .run(token); + if (r.changes === 0) { + return res + .status(404) + .send(page("This unsubscribe link is no longer valid.")); + } + return res.send( + page( + "You've been unsubscribed from the daily digest. You can turn it back on anytime in Settings.", + ), + ); + } catch (err) { + console.error("[digest] unsubscribe failed:", err); + return res + .status(500) + .send(page("Something went wrong. Please try again later.")); + } + }); +} diff --git a/server/db.js b/server/db.js index 9a09835..4acc471 100644 --- a/server/db.js +++ b/server/db.js @@ -41,10 +41,24 @@ CREATE TABLE IF NOT EXISTS users ( -- NOT used for auth decisions — just data for the operator to grep -- when an abuse pattern shows up in the admin dashboard. signup_ip TEXT, - signup_user_agent TEXT + signup_user_agent TEXT, + -- Daily Digest (opt-in, multi-mode): a daily email of the user's last + -- ~24h of library recaps. Off by default. last_digest_at is the + -- ms-epoch watermark of the last send; the scan covers recaps created + -- AFTER it (dedup), and opt-in stamps it to "now" so the first digest + -- doesn't dump the whole backlog. NULL = never sent. + -- digest_unsub_token is a per-user random string for the one-click + -- unsubscribe link in each digest email (no login needed); minted + -- lazily on first send. + digest_enabled INTEGER NOT NULL DEFAULT 0, + last_digest_at INTEGER, + digest_unsub_token TEXT ); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip); +-- NB: idx_users_unsub_token is created in migrateUserDigestPrefs, not here +-- — SCHEMA_SQL runs before the column migration on existing DBs, so an +-- index over digest_unsub_token here would fail with "no such column". -- ── sessions ─────────────────────────────────────────────────────────── -- Server-side session store so we can revoke individual sessions from @@ -293,6 +307,7 @@ export async function initDb({ dataDir }) { migrateTenantCreditsSchema(db); migrateMagicLinkTokensTrialCookie(db); migrateUsersTier(db); + migrateUserDigestPrefs(db); dbInstance = db; console.log(`[db] opened ${dbPath} (multi-tenant store)`); @@ -314,6 +329,35 @@ function migrateUsersTier(db) { } } +// Daily Digest — add the opt-in columns to existing DBs (fresh installs get +// them from SCHEMA_SQL). Idempotent: ALTERs only the columns still missing. +function migrateUserDigestPrefs(db) { + let cols; + try { + cols = db.prepare("PRAGMA table_info(users)").all(); + } catch { + return; + } + if (!cols.some((c) => c.name === "digest_enabled")) { + db.exec("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0"); + console.log("[db] added users.digest_enabled column (daily-digest)"); + } + if (!cols.some((c) => c.name === "last_digest_at")) { + db.exec("ALTER TABLE users ADD COLUMN last_digest_at INTEGER"); + console.log("[db] added users.last_digest_at column (daily-digest)"); + } + if (!cols.some((c) => c.name === "digest_unsub_token")) { + db.exec("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT"); + console.log("[db] added users.digest_unsub_token column (daily-digest)"); + } + // Created here (not in SCHEMA_SQL) so it runs AFTER the column exists on + // both fresh and migrated DBs. Idempotent. Keeps the public unsubscribe + // token lookup off a full-table scan. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_users_unsub_token ON users(digest_unsub_token)", + ); +} + // v0.2.92 — split the single tenant_credits.balance into two buckets // (purchased + replenish) so we can refill the latter periodically // without wiping the former. diff --git a/server/email-template.js b/server/email-template.js index 8abf586..12c8dee 100644 --- a/server/email-template.js +++ b/server/email-template.js @@ -167,6 +167,110 @@ export function renderSubscriptionReminderEmail({ return { subject, text, html }; } +// renderDigestEmail({ brandName, episodes, overflowCount, manageUrl, +// unsubscribeUrl }) → { subject, text, html } +// episodes: [{ title, type, url, overview }] — already capped + synthesized +// by the scan. overflowCount: how many more are in the library beyond the +// shown set (0 = none). Same minimal, spam-filter-friendly style as the +// other emails: no images, inline CSS, one CTA. The unsubscribe link is a +// one-click GET (no login) — required for deliverability + consent. +export function renderDigestEmail({ + brandName = "Recaps", + episodes = [], + overflowCount = 0, + manageUrl, + unsubscribeUrl, +}) { + const n = episodes.length; + const subject = + n === 1 + ? `Your ${brandName} digest: 1 new recap` + : `Your ${brandName} digest: ${n} new recaps`; + + const typeLabel = (t) => + t === "podcast" ? "Podcast" : t === "youtube" ? "Video" : "Recording"; + + const epText = episodes + .map((ep) => + [ + `${ep.title || "Untitled"} (${typeLabel(ep.type)})`, + ep.overview || "", + ep.url || "", + ] + .filter(Boolean) + .join("\n"), + ) + .join("\n\n"); + + const text = [ + `Here's what you added to ${brandName} in the last day:`, + "", + epText, + "", + overflowCount > 0 + ? `…and ${overflowCount} more in your library: ${manageUrl}` + : `Open your library: ${manageUrl}`, + "", + `You're receiving this because you turned on the daily digest. Unsubscribe: ${unsubscribeUrl}`, + ].join("\n"); + + const episodeBlocks = episodes + .map((ep) => { + const title = escapeHtml(ep.title || "Untitled"); + const titleHtml = ep.url + ? `${title}` + : title; + return ` + + +
${escapeHtml(typeLabel(ep.type))}
+
${titleHtml}
+
${escapeHtml(ep.overview || "")}
+ + + `; + }) + .join(""); + + const overflowHtml = + overflowCount > 0 + ? `…and ${overflowCount} more in your library.` + : ""; + + const html = ` + + + + + + +
+ + + + + ${episodeBlocks} + ${overflowHtml} + + + + + + +
+ Your ${escapeHtml(brandName)} digest +
+ Open your library +
+ You're receiving this because you turned on the daily digest. Unsubscribe anytime, or manage it in Settings. +
+
+ +`; + + return { subject, text, html }; +} + function escapeHtml(s) { return String(s) .replace(/&/g, "&") diff --git a/server/history.js b/server/history.js index 313b15c..e1e0e20 100644 --- a/server/history.js +++ b/server/history.js @@ -220,6 +220,43 @@ export async function loadSession(scope, id) { } } +// List a scope's saved sessions as lightweight metadata (no entries / +// chunks), oldest first. The daily-digest scan uses this to pick recaps +// created after a watermark before loading each full record for +// synthesis. Returns [] when the scope has no library yet (or the id is +// malformed — safeComponent throws inside scopeDir, caught here). +export async function listScopeSessions(scope) { + let dir; + try { + dir = scopeDir(scope); + } catch { + return []; + } + let files = []; + try { + files = await fs.readdir(dir); + } catch { + return []; + } + const out = []; + for (const file of files.filter( + (f) => f.endsWith(".json") && !f.startsWith("_") && !ROOT_SIDECARS.has(f), + )) { + try { + const data = JSON.parse(await fs.readFile(path.join(dir, file), "utf-8")); + out.push({ + id: data.id, + title: data.title, + type: data.type || "youtube", + url: data.url, + createdAt: data.createdAt, + }); + } catch {} + } + out.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); + return out; +} + // Shallow-merge `patch` into a session record on disk (e.g. to stamp // `summaryAudio` availability). No-op-safe: returns null if the record // is missing rather than throwing. diff --git a/server/index.js b/server/index.js index ada3a8b..caf925e 100644 --- a/server/index.js +++ b/server/index.js @@ -264,6 +264,15 @@ if (RECAP_MODE === "multi") { // public URL + relay being configured, so it's a safe no-op until then. const { startReminderScheduler } = await import("./subscription-reminders.js"); startReminderScheduler(); + // Daily Digest: opt-in (off by default) once-a-day email of a user's + // last ~24h of library recaps. Same self-gating shape as reminders — + // no-op until SMTP + public URL are set. The one-click unsubscribe GET + // is public (whitelisted in tenant-auth) since the email has no session. + const { startDigestScheduler, setupDigestRoutes } = await import( + "./daily-digest.js" + ); + setupDigestRoutes(app); + startDigestScheduler(); // /api/account/whoami — frontend hits this on every page load to // determine which UI state to render: diff --git a/server/tenant-auth.js b/server/tenant-auth.js index c6da17c..c5995cc 100644 --- a/server/tenant-auth.js +++ b/server/tenant-auth.js @@ -35,6 +35,7 @@ const PUBLIC_PATH_PREFIXES = [ "/api/health", "/api/auth/", // future client-facing auth shims (CSRF token issue, etc.) "/api/btcpay/webhook", // BTCPay needs to reach this without a session + "/api/digest/unsubscribe", // one-click unsubscribe from a digest email (no session) "/api/network-mode", // returns lan-vs-local; safe to expose "/api/relay/status", // public relay capabilities — pre-trial visibility "/api/account/whoami", // returns state — anonymous visitors must call this diff --git a/server/test/daily-digest.test.js b/server/test/daily-digest.test.js new file mode 100644 index 0000000..0d99d12 --- /dev/null +++ b/server/test/daily-digest.test.js @@ -0,0 +1,248 @@ +// Pure / injectable-logic tests for Daily Digest episode synthesis. The +// relay round-trip and FS cache write-back aren't exercised here (a fake +// provider stands in, save:false skips disk); this nails prompt shaping, +// the operator-string scrub backstop, and the get-or-generate cache gate. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + buildOverviewPrompt, + scrubOperatorStrings, + synthesizeEpisodeOverview, + getOrCreateEpisodeOverview, + selectDigestEpisodes, + scopeForUser, + nextDigestWatermark, +} from "../daily-digest.js"; +import { renderDigestEmail } from "../email-template.js"; + +const record = (over = {}) => ({ + id: "1700000000000-abc", + title: "How Markets Work", + type: "podcast", + chunks: [ + { title: "Supply & demand", summary: "Prices clear where the two curves meet." }, + { title: "Information", summary: "Asymmetry distorts outcomes." }, + ], + ...over, +}); + +// A provider stub recording calls, returning a fixed analyze result. +const fakeProvider = (text, sink = {}) => ({ + async analyzeText(args) { + sink.calls = (sink.calls || 0) + 1; + sink.lastArgs = args; + return { text }; + }, +}); + +describe("buildOverviewPrompt", () => { + test("includes title, type, and each topic's title + summary", () => { + const p = buildOverviewPrompt(record()); + assert.match(p, /"How Markets Work"/); + assert.match(p, /podcast episode/); + assert.match(p, /- Supply & demand: Prices clear/); + assert.match(p, /- Information: Asymmetry distorts/); + assert.match(p, /1–2 paragraph/); + }); + + test("a topic with no summary still contributes its title", () => { + const p = buildOverviewPrompt( + record({ chunks: [{ title: "Loose ends" }] }), + ); + assert.match(p, /- Loose ends/); + assert.doesNotMatch(p, /Loose ends:/); // no trailing colon when summary absent + }); + + test("null-safe: missing fields fall back", () => { + const p = buildOverviewPrompt({}); + assert.match(p, /"Untitled"/); + assert.match(p, /recording/); // unknown type + }); +}); + +describe("scrubOperatorStrings", () => { + test("removes operator/infra tokens", () => { + const out = scrubOperatorStrings( + "Routed via Spark Control and the vLLM box on Parakeet.", + ); + assert.doesNotMatch(out, /spark control/i); + assert.doesNotMatch(out, /vllm/i); + assert.doesNotMatch(out, /parakeet/i); + }); + + test("strips LAN hosts and private IPs, keeps public/content data", () => { + assert.doesNotMatch( + scrubOperatorStrings("see http://immense-voyage.local/admin"), + /\.local/, + ); + assert.doesNotMatch(scrubOperatorStrings("host 192.168.1.42 here"), /192\.168/); + // A public dotted quad in content is the user's data, not a leak. + assert.match(scrubOperatorStrings("DNS is 8.8.8.8"), /8\.8\.8\.8/); + }); + + test("leaves ordinary prose intact", () => { + const clean = "The episode covers supply, demand, and information costs."; + assert.equal(scrubOperatorStrings(clean), clean); + }); + + test("null-safe", () => { + assert.equal(scrubOperatorStrings(null), ""); + assert.equal(scrubOperatorStrings(""), ""); + }); +}); + +describe("synthesizeEpisodeOverview", () => { + test("scrubs the model result and passes a stable per-episode jobId", async () => { + const sink = {}; + const out = await synthesizeEpisodeOverview(record(), { + provider: fakeProvider("A clear overview from Spark Control.", sink), + }); + assert.doesNotMatch(out, /spark control/i); + assert.equal(sink.calls, 1); + assert.equal(sink.lastArgs.jobId, "digest-1700000000000-abc"); + }); + + test("throws when there are no topics", async () => { + await assert.rejects( + () => synthesizeEpisodeOverview(record({ chunks: [] }), { provider: fakeProvider("x") }), + /no topic summaries/, + ); + }); + + test("throws when the model returns nothing usable", async () => { + await assert.rejects( + () => synthesizeEpisodeOverview(record(), { provider: fakeProvider(" ") }), + /empty synthesis result/, + ); + }); +}); + +describe("getOrCreateEpisodeOverview", () => { + test("cache hit returns stored overview without calling the provider", async () => { + const sink = {}; + const res = await getOrCreateEpisodeOverview({ + record: record({ digestOverview: "Already done." }), + provider: fakeProvider("fresh", sink), + save: false, + }); + assert.equal(res.cached, true); + assert.equal(res.overview, "Already done."); + assert.equal(sink.calls || 0, 0); + }); + + test("cache miss synthesizes (save:false skips disk)", async () => { + const sink = {}; + const res = await getOrCreateEpisodeOverview({ + record: record(), + provider: fakeProvider("A fresh overview.", sink), + save: false, + }); + assert.equal(res.cached, false); + assert.equal(res.overview, "A fresh overview."); + assert.equal(sink.calls, 1); + }); +}); + +describe("selectDigestEpisodes", () => { + const at = (iso, id) => ({ id, title: id, type: "youtube", url: "", createdAt: iso }); + const sessions = [ + at("2026-06-14T00:00:00.000Z", "old"), + at("2026-06-15T09:00:00.000Z", "a"), + at("2026-06-15T10:00:00.000Z", "b"), + ]; + const watermark = new Date("2026-06-15T08:00:00.000Z").getTime(); + + test("keeps only recaps created after the watermark, oldest first", () => { + const { episodes, total, overflow } = selectDigestEpisodes(sessions, watermark); + assert.deepEqual(episodes.map((e) => e.id), ["a", "b"]); + assert.equal(total, 2); + assert.equal(overflow, 0); + }); + + test("caps the list and reports the overflow", () => { + const many = Array.from({ length: 13 }, (_, i) => + at(`2026-06-15T1${i % 10}:00:00.000Z`, `e${i}`), + ); + const { episodes, overflow, total } = selectDigestEpisodes(many, 0, 10); + assert.equal(episodes.length, 10); + assert.equal(overflow, 3); + assert.equal(total, 13); + }); + + test("empty / malformed-date inputs", () => { + assert.deepEqual(selectDigestEpisodes([], watermark).episodes, []); + assert.deepEqual(selectDigestEpisodes(null, watermark).episodes, []); + assert.deepEqual( + selectDigestEpisodes([at("not-a-date", "x")], 0).episodes, + [], + ); + }); + + test("null watermark treats everything as fresh", () => { + assert.equal(selectDigestEpisodes(sessions, null).total, 3); + }); +}); + +describe("nextDigestWatermark", () => { + const t = (iso) => new Date(iso).getTime(); + const e1 = "2026-06-15T09:00:00.000Z"; + const e2 = "2026-06-15T10:00:00.000Z"; + const e3 = "2026-06-15T11:00:00.000Z"; + + test("all sent → newest sent createdAt", () => { + assert.equal(nextDigestWatermark([e1, e2, e3], []), t(e3)); + }); + + test("never advances past the oldest failure (so it's retried)", () => { + // sent e1 & e3, e2 failed → watermark just before e2, NOT now/e3. + assert.equal(nextDigestWatermark([e1, e3], [e2]), t(e2) - 1); + }); + + test("failures newer than everything sent don't pull the watermark back", () => { + assert.equal(nextDigestWatermark([e1, e2], [e3]), t(e2)); + }); + + test("nothing sent → null (caller must not advance)", () => { + assert.equal(nextDigestWatermark([], [e1]), null); + assert.equal(nextDigestWatermark(null, null), null); + }); +}); + +describe("scopeForUser", () => { + test("admin keeps the owner scope; everyone else is their id", () => { + assert.equal(scopeForUser({ id: "u1", is_admin: 1 }), "owner"); + assert.equal(scopeForUser({ id: "u1", is_admin: 0 }), "u1"); + }); +}); + +describe("renderDigestEmail", () => { + const episodes = [ + { title: "First", type: "podcast", url: "https://x/1", overview: "Ov one." }, + { title: "Second", type: "youtube", url: "", overview: "Ov two." }, + ]; + + test("subject reflects count; body carries overviews + unsubscribe link", () => { + const m = renderDigestEmail({ + episodes, + overflowCount: 3, + manageUrl: "https://recaps.cc/", + unsubscribeUrl: "https://recaps.cc/api/digest/unsubscribe?token=tok", + }); + assert.match(m.subject, /2 new recaps/); + assert.match(m.html, /Ov one\./); + assert.match(m.html, /unsubscribe\?token=tok/); + assert.match(m.html, /3 more/); + assert.match(m.text, /Unsubscribe: https:\/\/recaps\.cc/); + }); + + test("singular subject for one recap", () => { + const m = renderDigestEmail({ + episodes: [episodes[0]], + manageUrl: "https://recaps.cc/", + unsubscribeUrl: "https://recaps.cc/u", + }); + assert.match(m.subject, /1 new recap\b/); + }); +}); diff --git a/startos/versions/index.ts b/startos/versions/index.ts index e8a93da..1e2a444 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -176,8 +176,9 @@ import { v_0_2_154 } from './v0.2.154' import { v_0_2_155 } from './v0.2.155' import { v_0_2_156 } from './v0.2.156' import { v_0_2_157 } from './v0.2.157' +import { v_0_2_158 } from './v0.2.158' export const versionGraph = VersionGraph.of({ - current: v_0_2_157, - other: [v_0_2_156, v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], + current: v_0_2_158, + other: [v_0_2_157, v_0_2_156, v_0_2_155, v_0_2_154, v_0_2_153, v_0_2_152, v_0_2_151, v_0_2_150, v_0_2_149, v_0_2_148, v_0_2_147, v_0_2_146, v_0_2_145, v_0_2_144, v_0_2_143, v_0_2_142, v_0_2_141, v_0_2_140, v_0_2_139, v_0_2_138, v_0_2_137, v_0_2_136, v_0_2_135, v_0_2_134, v_0_2_133, v_0_2_132, v_0_2_131, v_0_2_130, v_0_2_129, v_0_2_128, v_0_2_127, v_0_2_126, v_0_2_125, v_0_2_124, v_0_2_123, v_0_2_122, v_0_2_121, v_0_2_120, v_0_2_119, v_0_2_118, v_0_2_117, v_0_2_116, v_0_2_115, v_0_2_114, v_0_2_113, v_0_2_112, v_0_2_111, v_0_2_110, v_0_2_109, v_0_2_108, v_0_2_107, v_0_2_106, v_0_2_105, v_0_2_104, v_0_2_103, v_0_2_102, v_0_2_101, v_0_2_100, v_0_2_99, v_0_2_98, v_0_2_97, v_0_2_96, v_0_2_95, v_0_2_94, v_0_2_93, v_0_2_92, v_0_2_91, v_0_2_90, v_0_2_89, v_0_2_88, v_0_2_87, v_0_2_86, v_0_2_85, v_0_2_84, v_0_2_83, v_0_2_82, v_0_2_81, v_0_2_80, v_0_2_79, v_0_2_78, v_0_2_77, v_0_2_76, v_0_2_75, v_0_2_74, v_0_2_73, v_0_2_72, v_0_2_71, v_0_2_70, v_0_2_69, v_0_2_68, v_0_2_67, v_0_2_66, v_0_2_65, v_0_2_64, v_0_2_63, v_0_2_62, v_0_2_61, v_0_2_60, v_0_2_59, v_0_2_58, v_0_2_57, v_0_2_56, v_0_2_55, v_0_2_54, v_0_2_53, v_0_2_52, v_0_2_51, v_0_2_50, v_0_2_49, v_0_2_48, v_0_2_47, v_0_2_46, v_0_2_45, v_0_2_44, v_0_2_43, v_0_2_42, v_0_2_41, v_0_2_40, v_0_2_39, v_0_2_38, v_0_2_37, v_0_2_36, v_0_2_35, v_0_2_34, v_0_2_33, v_0_2_32, v_0_2_31, v_0_2_30, v_0_2_29, v_0_2_28, v_0_2_27, v_0_2_26, v_0_2_25, v_0_2_24, v_0_2_23, v_0_2_22, v_0_2_21, v_0_2_20, v_0_2_19, v_0_2_18, v_0_2_17, v_0_2_16, v_0_2_15, v_0_2_14, v_0_2_13, v_0_2_12, v_0_2_11, v_0_2_10, v_0_2_9, v_0_2_8, v_0_2_7, v_0_2_6, v_0_2_5, v_0_2_4, v_0_2_3, v_0_2_2, v_0_2_1, v_0_2_0, v_0_1_18, v_0_1_17, v_0_1_16, v_0_1_15, v_0_1_14, v_0_1_13, v_0_1_12, v_0_1_11, v_0_1_10, v_0_1_9, v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], }) diff --git a/startos/versions/v0.2.158.ts b/startos/versions/v0.2.158.ts new file mode 100644 index 0000000..817f2d3 --- /dev/null +++ b/startos/versions/v0.2.158.ts @@ -0,0 +1,13 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v_0_2_158 = VersionInfo.of({ + version: '0.2.158:0', + releaseNotes: { + en_US: + 'New: opt-in Daily Digest — a once-a-day email summarizing the recaps you added to your library in the last 24 hours, each as a short synthesized overview. Off by default; turn it on in Settings, and every email has a one-click unsubscribe.', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +})