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.
This commit is contained in:
@@ -127,13 +127,13 @@ unsure whether a change is contract-affecting, assume it is and check.
|
|||||||
|
|
||||||
## Current state
|
## 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`.
|
**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.
|
**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:**
|
**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.
|
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.
|
||||||
|
|||||||
+64
-12
@@ -74,20 +74,72 @@ the user's credits. Confirm.
|
|||||||
|
|
||||||
## Open questions (defaults chosen; confirm or adjust)
|
## 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)?
|
2. **Send hour** — 08:00 server time (default)?
|
||||||
3. **Single-mode operator digest** — defer to a follow-on (default: multi-mode only v1)?
|
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
|
4. **Relay contract** — ~~does an existing relay endpoint (`/relay/analyze`) fit~~
|
||||||
"summarize these topic summaries into 2 paragraphs" call, or is a small new relay
|
**RESOLVED 2026-06-15: `/relay/analyze` fits as-is, no new relay capability.** The
|
||||||
capability/prompt-mode needed? If new, update `../recap-relay` + both repos'
|
route (`recap-relay/server/routes/analyze.js`) takes a free-form `{ prompt: string }`
|
||||||
`AGENTS.md`/`ROADMAP.md` per the cross-repo rule. **Resolve before phase 2.**
|
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
|
## Build phases
|
||||||
|
|
||||||
1. Schema + opt-in toggle (migration, account endpoint, settings UI).
|
1. **BUILT 2026-06-15.** Schema + opt-in toggle. `db.js`: `users.digest_enabled`
|
||||||
2. Synthesis + cache (relay call + write-back + operator-string scrub). Resolve the
|
(default 0) + `users.last_digest_at` (ms, nullable) via SCHEMA_SQL +
|
||||||
relay-contract question first.
|
`migrateUserDigestPrefs`. `account-routes.js`: `GET`/`POST /api/account/digest`
|
||||||
3. Email template + scan loop + scheduler + watermark dedup + overflow cap.
|
(enabling stamps `last_digest_at = now` so the first send isn't a backlog dump).
|
||||||
4. Operator test trigger.
|
`public/index.html`: settings-modal toggle (`renderDigestBlock` + `loadMyDigest` /
|
||||||
5. Tests — pure-function coverage (episode selection vs watermark, cap/overflow, empty
|
`setDigestEnabled`, optimistic with revert).
|
||||||
→ skip), in the `subscription-reminders` test style.
|
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.
|
||||||
|
|||||||
@@ -2362,6 +2362,9 @@
|
|||||||
// User's own active sessions (lite-settings tenant view). Loaded
|
// User's own active sessions (lite-settings tenant view). Loaded
|
||||||
// on demand when the Settings modal opens.
|
// on demand when the Settings modal opens.
|
||||||
mySessions: { rows: null, loading: false, error: null },
|
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
|
// "Take Recap home" — fetches the tenant's raw license key on
|
||||||
// demand. We don't load this in /api/account/whoami because the
|
// demand. We don't load this in /api/account/whoami because the
|
||||||
// key is a bearer credential we'd rather not pass through the
|
// key is a bearer credential we'd rather not pass through the
|
||||||
@@ -7343,6 +7346,7 @@
|
|||||||
${state.account?.user ? renderClaimPurchaseBlock() : ""}
|
${state.account?.user ? renderClaimPurchaseBlock() : ""}
|
||||||
${state.account?.user ? renderPasswordBlock() : ""}
|
${state.account?.user ? renderPasswordBlock() : ""}
|
||||||
${state.account?.user ? renderMySessionsBlock() : ""}
|
${state.account?.user ? renderMySessionsBlock() : ""}
|
||||||
|
${state.account?.user ? renderDigestBlock() : ""}
|
||||||
${renderLibraryTransfer()}
|
${renderLibraryTransfer()}
|
||||||
${state.account?.user ? renderTenantDangerZone() : ""}
|
${state.account?.user ? renderTenantDangerZone() : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -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 `
|
||||||
|
<label class="field-label" style="margin-top:14px;">Daily digest</label>
|
||||||
|
<div style="border:1px solid #1e293b;background:rgba(15,23,42,0.4);border-radius:8px;padding:12px;">
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:#cbd5e1;cursor:${checkboxAttrs ? "default" : "pointer"};">
|
||||||
|
<input type="checkbox" ${d.enabled ? "checked" : ""} ${checkboxAttrs}
|
||||||
|
onchange="setDigestEnabled(this.checked)" style="margin:2px 0 0;" />
|
||||||
|
<span>
|
||||||
|
Email me a daily digest
|
||||||
|
<span style="color:#64748b;display:block;font-size:10px;margin-top:2px;line-height:1.5;">
|
||||||
|
${d.enabled === null
|
||||||
|
? "Loading…"
|
||||||
|
: "A once-a-day email summarizing the recaps you added to your library in the last 24 hours. Off by default; skipped on days with nothing new."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// Danger Zone — sits at the very bottom of the tenant lite-settings
|
// Danger Zone — sits at the very bottom of the tenant lite-settings
|
||||||
// modal. One action for now (Delete Account); future destructive
|
// modal. One action for now (Delete Account); future destructive
|
||||||
// self-actions land here. Visually muted by default to avoid being
|
// self-actions land here. Visually muted by default to avoid being
|
||||||
@@ -10264,6 +10295,7 @@
|
|||||||
loadAdminActivity(state.ops.activityHours || 24);
|
loadAdminActivity(state.ops.activityHours || 24);
|
||||||
} else if (state.account?.user) {
|
} else if (state.account?.user) {
|
||||||
loadMySessions();
|
loadMySessions();
|
||||||
|
loadMyDigest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render();
|
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) {
|
async function revokeMySession(sessionId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────
|
// ── List all tenants ───────────────────────────────────────────────────
|
||||||
app.get("/api/admin/tenants", requireOperator, (req, res) => {
|
app.get("/api/admin/tenants", requireOperator, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
`<!doctype html><html><body style="margin:0;padding:48px 16px;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;text-align:center;color:#333;"><div style="max-width:420px;margin:0 auto;background:#fff;border-radius:8px;padding:32px;font-size:15px;line-height:1.6;">${msg}</div></body></html>`;
|
||||||
|
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."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+45
-1
@@ -41,10 +41,24 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
-- NOT used for auth decisions — just data for the operator to grep
|
-- NOT used for auth decisions — just data for the operator to grep
|
||||||
-- when an abuse pattern shows up in the admin dashboard.
|
-- when an abuse pattern shows up in the admin dashboard.
|
||||||
signup_ip TEXT,
|
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_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
|
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 ───────────────────────────────────────────────────────────
|
-- ── sessions ───────────────────────────────────────────────────────────
|
||||||
-- Server-side session store so we can revoke individual sessions from
|
-- Server-side session store so we can revoke individual sessions from
|
||||||
@@ -293,6 +307,7 @@ export async function initDb({ dataDir }) {
|
|||||||
migrateTenantCreditsSchema(db);
|
migrateTenantCreditsSchema(db);
|
||||||
migrateMagicLinkTokensTrialCookie(db);
|
migrateMagicLinkTokensTrialCookie(db);
|
||||||
migrateUsersTier(db);
|
migrateUsersTier(db);
|
||||||
|
migrateUserDigestPrefs(db);
|
||||||
|
|
||||||
dbInstance = db;
|
dbInstance = db;
|
||||||
console.log(`[db] opened ${dbPath} (multi-tenant store)`);
|
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
|
// v0.2.92 — split the single tenant_credits.balance into two buckets
|
||||||
// (purchased + replenish) so we can refill the latter periodically
|
// (purchased + replenish) so we can refill the latter periodically
|
||||||
// without wiping the former.
|
// without wiping the former.
|
||||||
|
|||||||
@@ -167,6 +167,110 @@ export function renderSubscriptionReminderEmail({
|
|||||||
return { subject, text, html };
|
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
|
||||||
|
? `<a href="${escapeAttr(ep.url)}" style="color:#111;text-decoration:none;">${title}</a>`
|
||||||
|
: title;
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="padding-bottom:20px;border-bottom:1px solid #eee;">
|
||||||
|
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:#999;padding-bottom:4px;">${escapeHtml(typeLabel(ep.type))}</div>
|
||||||
|
<div style="font-size:16px;font-weight:600;color:#111;padding-bottom:8px;line-height:1.35;">${titleHtml}</div>
|
||||||
|
<div style="font-size:14px;line-height:1.55;color:#444;">${escapeHtml(ep.overview || "")}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td style="height:20px;"></td></tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const overflowHtml =
|
||||||
|
overflowCount > 0
|
||||||
|
? `<tr><td style="font-size:13px;color:#888;padding-bottom:16px;">…and ${overflowCount} more in your library.</td></tr>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="520" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:20px;">
|
||||||
|
Your ${escapeHtml(brandName)} digest
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${episodeBlocks}
|
||||||
|
${overflowHtml}
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:8px 0 24px;">
|
||||||
|
<a href="${escapeAttr(manageUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">Open your library</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||||
|
You're receiving this because you turned on the daily digest. <a href="${escapeAttr(unsubscribeUrl)}" style="color:#888;">Unsubscribe</a> anytime, or manage it in Settings.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -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
|
// Shallow-merge `patch` into a session record on disk (e.g. to stamp
|
||||||
// `summaryAudio` availability). No-op-safe: returns null if the record
|
// `summaryAudio` availability). No-op-safe: returns null if the record
|
||||||
// is missing rather than throwing.
|
// is missing rather than throwing.
|
||||||
|
|||||||
@@ -264,6 +264,15 @@ if (RECAP_MODE === "multi") {
|
|||||||
// public URL + relay being configured, so it's a safe no-op until then.
|
// public URL + relay being configured, so it's a safe no-op until then.
|
||||||
const { startReminderScheduler } = await import("./subscription-reminders.js");
|
const { startReminderScheduler } = await import("./subscription-reminders.js");
|
||||||
startReminderScheduler();
|
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
|
// /api/account/whoami — frontend hits this on every page load to
|
||||||
// determine which UI state to render:
|
// determine which UI state to render:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const PUBLIC_PATH_PREFIXES = [
|
|||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
|
"/api/auth/", // future client-facing auth shims (CSRF token issue, etc.)
|
||||||
"/api/btcpay/webhook", // BTCPay needs to reach this without a session
|
"/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/network-mode", // returns lan-vs-local; safe to expose
|
||||||
"/api/relay/status", // public relay capabilities — pre-trial visibility
|
"/api/relay/status", // public relay capabilities — pre-trial visibility
|
||||||
"/api/account/whoami", // returns state — anonymous visitors must call this
|
"/api/account/whoami", // returns state — anonymous visitors must call this
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_155 } from './v0.2.155'
|
||||||
import { v_0_2_156 } from './v0.2.156'
|
import { v_0_2_156 } from './v0.2.156'
|
||||||
import { v_0_2_157 } from './v0.2.157'
|
import { v_0_2_157 } from './v0.2.157'
|
||||||
|
import { v_0_2_158 } from './v0.2.158'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_2_157,
|
current: v_0_2_158,
|
||||||
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],
|
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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }) => {},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user