User feedback + expanded privacy-respecting admin stats

Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
  honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
  header, the mobile top bar (logo moves left), the footer, and /account. Feedback
  section in /admin.

Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
  not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
  paywalled_source_open. Card title/image opens /a (no double-count); history
  records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
  emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
  deferred to a future monitoring dashboard.) 131 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-05 12:58:49 +00:00
parent cfde4e22db
commit 427210ac3e
16 changed files with 460 additions and 38 deletions
+64 -2
View File
@@ -149,6 +149,13 @@ def _kick_summary(article_id: int, background_tasks: BackgroundTasks) -> None:
background_tasks.add_task(_run_summary, article_id)
def _feedback_email_safe(addr: str, category: str, message: str, contact: str | None, who: str) -> None:
try:
email_send.send_feedback(addr, category, message, contact, who)
except Exception:
pass
def _send_link_safe(email: str, link: str) -> None:
"""Send the magic link, swallowing failures (runs off the request path)."""
try:
@@ -359,8 +366,23 @@ class EventBody(BaseModel):
visitor: str | None = None
# The only event kinds we record (per the agreed vocabulary).
_EVENT_KINDS = {"visit", "open", "share_ub", "copy_source", "native_share", "source_click"}
class FeedbackBody(BaseModel):
category: str = "other"
message: str = ""
email: str | None = None
visitor: str | None = None
hp: str | None = None # honeypot — bots fill it, humans don't
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
# The only event kinds we record. All aggregate, non-personal.
_EVENT_KINDS = {
"visit", "open", "summary_viewed", "full_story", "source_click",
"share_ub", "copy_source", "native_share",
"not_today", "less_like_this", "hide_topic",
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
}
def _visitor_hash(token: str | None) -> str:
@@ -723,6 +745,46 @@ def create_app() -> FastAPI:
conn.commit()
return {"ok": True} # always identical; dedup'd by the unique key
@app.post("/api/feedback")
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
if body.hp: # honeypot tripped → accept silently, store nothing
return {"ok": True}
message = (body.message or "").strip()[:4000]
if not message:
raise HTTPException(status_code=422, detail="Please add a short message.")
category = body.category if body.category in _FEEDBACK_CATEGORIES else "other"
email = ((body.email or "").strip()[:200]) or None
vh = _visitor_hash(body.visitor)
with get_conn() as conn:
if vh: # light flood cap per anonymous token per day
recent = conn.execute(
"SELECT COUNT(*) FROM feedback WHERE visitor_hash = ? AND day = date('now')", (vh,)
).fetchone()[0]
if recent >= 8:
return {"ok": True}
user = _current_user(conn, request)
conn.execute(
"INSERT INTO feedback (category, message, contact_email, user_id, visitor_hash, day) "
"VALUES (?, ?, ?, ?, ?, date('now'))",
(category, message, email, user["id"] if user else None, vh),
)
conn.commit()
who = user["email"] if user else "anonymous visitor"
for addr in ADMIN_EMAILS:
background_tasks.add_task(_feedback_email_safe, addr, category, message, email, who)
return {"ok": True}
@app.get("/api/admin/feedback")
def admin_feedback(request: Request) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
rows = conn.execute(
"SELECT f.id, f.category, f.message, f.contact_email, f.created_at, "
"u.email AS user_email FROM feedback f LEFT JOIN users u ON u.id = f.user_id "
"ORDER BY f.created_at DESC LIMIT 100"
).fetchall()
return [dict(r) for r in rows]
@app.get("/api/admin/stats")
def admin_stats(request: Request) -> dict:
with get_conn() as conn: