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:
Keysat
2026-06-15 19:50:48 -05:00
parent 962423ca10
commit b4fa5d7be8
14 changed files with 1144 additions and 17 deletions
+45 -1
View File
@@ -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.