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 calendar as _cal
import re import re
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
@ -809,3 +810,33 @@ async def chat(
"prompt_tokens": result.prompt_tokens, "prompt_tokens": result.prompt_tokens,
"completion_tokens": result.completion_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.auth import CurrentUser, maybe_current_user, require_auth, require_token
from app.config import get_settings, load_groups from app.config import get_settings, load_groups
from app.db import get_session 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.access import paid_status
from app.services.referral_service import assign_code_if_missing from app.services.referral_service import assign_code_if_missing
from app.templates_env import templates 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}" 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( return templates.TemplateResponse(
request, "settings.html", request, "settings.html",
{ {
@ -155,5 +162,6 @@ async def settings_page(
"pending_count": int(pending_count), "pending_count": int(pending_count),
"converted_count": int(converted_count), "converted_count": int(converted_count),
"paid": paid_status(user), "paid": paid_status(user),
"last_email_send": last_email_send,
}, },
) )

View file

@ -96,6 +96,71 @@
</div> </div>
</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 --------------------------------------------- #} {# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div> <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)