diff --git a/app/routers/api.py b/app/routers/api.py index 66206f6..e76a625 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -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) diff --git a/app/routers/pages.py b/app/routers/pages.py index a00bf56..7790d18 100644 --- a/app/routers/pages.py +++ b/app/routers/pages.py @@ -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, }, ) diff --git a/app/templates/settings.html b/app/templates/settings.html index bab5cb9..466ffba 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -96,6 +96,71 @@ + {# --- Email digests block ------------------------------------------ #} +
+
Email digests
+

+ Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap. +

+ +
+
Subscription
+
+ +
+ One-click unsubscribe in every email. +
+
+
+ +
+
Reading level
+
+
+ + +
+
+
+ +
+
Last delivery
+
+ {% if last_email_send %}{{ last_email_send.sent_at.strftime("%Y-%m-%d %H:%M") }} UTC — {{ last_email_send.status }}{% else %}—{% endif %} +
+
+ +
+
+ + + {# --- Cloud sync block --------------------------------------------- #}
Cloud sync (encrypted)
diff --git a/tests/test_settings_digest_api.py b/tests/test_settings_digest_api.py new file mode 100644 index 0000000..0842c0a --- /dev/null +++ b/tests/test_settings_digest_api.py @@ -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)