settings: digest opt-in + tone (PATCH /api/settings/digest + UI)

Adds DigestPrefsIn/Out models, PATCH /api/settings/digest endpoint, email
digest section in settings.html, and last_email_send context in pages.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-25 23:23:03 +02:00
parent 5c89f4d04a
commit 14fe47103f
4 changed files with 176 additions and 1 deletions

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import calendar as _cal
import re
from datetime import date, datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
@ -809,3 +810,33 @@ async def chat(
"prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_tokens,
}
# ---------------------------------------------------------------------------
# Settings — digest preferences
# ---------------------------------------------------------------------------
class DigestPrefsIn(BaseModel):
opt_in: bool
tone: Literal["NOVICE", "INTERMEDIATE"]
class DigestPrefsOut(BaseModel):
opt_in: bool
tone: str
@router.patch("/settings/digest", response_model=DigestPrefsOut)
async def patch_digest_prefs(
payload: DigestPrefsIn,
principal: CurrentUser = Depends(require_token),
session: AsyncSession = Depends(get_session),
) -> DigestPrefsOut:
if principal.user is None:
# Admin bearer-token path — no per-user row to persist to.
raise HTTPException(status_code=400, detail="no_user_context")
principal.user.email_digest_opt_in = payload.opt_in
principal.user.digest_tone = payload.tone
await session.commit()
return DigestPrefsOut(opt_in=payload.opt_in, tone=payload.tone)

View file

@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import CurrentUser, maybe_current_user, require_auth, require_token
from app.config import get_settings, load_groups
from app.db import get_session
from app.models import Referral, StrategicLog, User
from app.models import EmailSend, Referral, StrategicLog, User
from app.services.access import paid_status
from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates
@ -147,6 +147,13 @@ async def settings_page(
invite_url = str(request.url_for("login_page")) + f"?ref={user.referral_code}"
last_email_send = (await session.execute(
select(EmailSend)
.where(EmailSend.user_id == user.id)
.order_by(desc(EmailSend.sent_at))
.limit(1)
)).scalar_one_or_none()
return templates.TemplateResponse(
request, "settings.html",
{
@ -155,5 +162,6 @@ async def settings_page(
"pending_count": int(pending_count),
"converted_count": int(converted_count),
"paid": paid_status(user),
"last_email_send": last_email_send,
},
)

View file

@ -96,6 +96,71 @@
</div>
</div>
{# --- Email digests block ------------------------------------------ #}
<div class="settings-section">
<div class="settings-section__head">Email digests</div>
<p class="settings-section__lede">
Editorial commentary delivered to your inbox. Daily for paid (Mon&ndash;Sat) plus the Sunday recap; free tier gets the Sunday recap.
</p>
<div class="settings-row">
<div class="settings-row__label">Subscription</div>
<div class="settings-row__value">
<label style="display:block; margin-bottom:8px;">
<input type="checkbox" id="digest-opt-in"
{% if user.email_digest_opt_in %}checked{% endif %}>
Send me digests
</label>
<div class="settings-row__hint" style="margin-bottom:8px;">
One-click unsubscribe in every email.
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Reading level</div>
<div class="settings-row__value">
<div style="display:flex; gap:14px;">
<label><input type="radio" name="digest-tone" value="NOVICE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'NOVICE' %}checked{% endif %}> Novice</label>
<label><input type="radio" name="digest-tone" value="INTERMEDIATE"
{% if (user.digest_tone or 'INTERMEDIATE') == 'INTERMEDIATE' %}checked{% endif %}> Intermediate</label>
</div>
</div>
</div>
<div class="settings-row">
<div class="settings-row__label">Last delivery</div>
<div class="settings-row__value settings-row__hint">
<span id="digest-last">{% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC &mdash; {{ last_email_send.status }}{% else %}&mdash;{% endif %}</span>
</div>
</div>
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
</div>
<script>
(function () {
const opt = document.getElementById('digest-opt-in');
const tones = document.querySelectorAll('input[name="digest-tone"]');
const fb = document.getElementById('digest-feedback');
if (!opt || !fb) return;
function patch() {
fb.textContent = 'Saving…';
const tone = Array.from(tones).find(t => t.checked)?.value || 'INTERMEDIATE';
fetch('/api/settings/digest', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opt_in: opt.checked, tone: tone }),
}).then(r => {
fb.textContent = r.ok ? 'Saved.' : 'Could not save — try again.';
}).catch(() => { fb.textContent = 'Network error.'; });
}
opt.addEventListener('change', patch);
tones.forEach(t => t.addEventListener('change', patch));
})();
</script>
{# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>

View file

@ -0,0 +1,71 @@
"""PATCH /api/settings/digest persists opt-in + tone."""
from __future__ import annotations
import asyncio
def _build(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import User
from app.routers import api as api_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/s.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with factory() as s:
s.add(User(id=1, email="u@x", tier="paid",
email_digest_opt_in=True))
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(api_router.router, prefix="/api")
return TestClient(app), sign_session(1)
def test_patch_round_trip(tmp_path):
client, sess = _build(tmp_path)
r = client.patch(
"/api/settings/digest",
json={"opt_in": False, "tone": "NOVICE"},
cookies={"cassandra_session": sess},
)
assert r.status_code == 200, r.text
assert r.json() == {"opt_in": False, "tone": "NOVICE"}
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 1)
assert u.email_digest_opt_in is False
assert u.digest_tone == "NOVICE"
asyncio.run(_check())
def test_patch_rejects_invalid_tone(tmp_path):
client, sess = _build(tmp_path)
r = client.patch(
"/api/settings/digest",
json={"opt_in": True, "tone": "PRO"},
cookies={"cassandra_session": sess},
)
assert r.status_code == 422
def test_patch_requires_auth(tmp_path):
client, _ = _build(tmp_path)
r = client.patch("/api/settings/digest",
json={"opt_in": True, "tone": "NOVICE"})
assert r.status_code in (401, 303)