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:
+64
-2
@@ -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:
|
||||
|
||||
@@ -224,6 +224,21 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
CREATE INDEX IF NOT EXISTS idx_events_day ON events(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_article ON events(article_id);
|
||||
|
||||
-- User feedback (idea / concern / bug / praise). Anonymous-friendly; optional
|
||||
-- contact email only if the person wants a reply. visitor_hash is for rate-limit
|
||||
-- only (the same hashed anonymous token used by analytics).
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
message TEXT NOT NULL,
|
||||
contact_email TEXT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
visitor_hash TEXT NOT NULL DEFAULT '',
|
||||
day TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,19 @@ def send_email(to: str, subject: str, text: str, html: str | None = None) -> Non
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
|
||||
"""Notify the admin of new user feedback (plain text is plenty here)."""
|
||||
subject = f"Upbeat Bytes feedback · {category}"
|
||||
reply = contact or "(none given)"
|
||||
text = (
|
||||
f"New feedback ({category})\n"
|
||||
f"From: {who}\n"
|
||||
f"Reply to: {reply}\n\n"
|
||||
f"{message}\n"
|
||||
)
|
||||
send_email(to, subject, text)
|
||||
|
||||
|
||||
def send_magic_link(to: str, link: str) -> None:
|
||||
"""Send a calm, single-purpose sign-in email."""
|
||||
subject = "Your Upbeat Bytes sign-in link"
|
||||
|
||||
+64
-17
@@ -222,35 +222,76 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
).fetchall()
|
||||
loyalty = {r["g"]: r["n"] for r in rows}
|
||||
|
||||
# An "open" = opening the article via our summary page (legacy 'open' + 'summary_viewed').
|
||||
OPEN = "e.kind IN ('open','summary_viewed')"
|
||||
|
||||
top_articles = [dict(r) for r in conn.execute(
|
||||
"SELECT e.article_id AS id, a.title, src.name AS source, COUNT(*) AS opens "
|
||||
"FROM events e JOIN articles a ON a.id=e.article_id JOIN sources src ON src.id=a.source_id "
|
||||
"WHERE e.kind='open' AND e.article_id>0 AND e.day>=date('now',?) "
|
||||
"GROUP BY e.article_id ORDER BY opens DESC LIMIT 12", (since,),
|
||||
f"SELECT e.article_id AS id, a.title, src.name AS source, COUNT(*) AS opens "
|
||||
f"FROM events e JOIN articles a ON a.id=e.article_id JOIN sources src ON src.id=a.source_id "
|
||||
f"WHERE {OPEN} AND e.article_id>0 AND e.day>=date('now',?) "
|
||||
f"GROUP BY e.article_id ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_groupings = [dict(r) for r in conn.execute(
|
||||
"SELECT t.tag, COUNT(*) AS opens FROM events e JOIN article_tags t ON t.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND e.day>=date('now',?) GROUP BY t.tag ORDER BY opens DESC LIMIT 12", (since,),
|
||||
f"SELECT t.tag, COUNT(*) AS opens FROM events e JOIN article_tags t ON t.article_id=e.article_id "
|
||||
f"WHERE {OPEN} AND e.day>=date('now',?) GROUP BY t.tag ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_topics = [dict(r) for r in conn.execute(
|
||||
"SELECT s.topic, COUNT(*) AS opens FROM events e JOIN article_scores s ON s.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND s.topic IS NOT NULL AND e.day>=date('now',?) "
|
||||
"GROUP BY s.topic ORDER BY opens DESC", (since,),
|
||||
f"SELECT s.topic, COUNT(*) AS opens FROM events e JOIN article_scores s ON s.article_id=e.article_id "
|
||||
f"WHERE {OPEN} AND s.topic IS NOT NULL AND e.day>=date('now',?) "
|
||||
f"GROUP BY s.topic ORDER BY opens DESC", (since,),
|
||||
)]
|
||||
|
||||
share_rows = conn.execute(
|
||||
"SELECT kind, COUNT(*) n FROM events "
|
||||
"WHERE kind IN ('share_ub','copy_source','native_share','source_click') AND day>=date('now',?) "
|
||||
"GROUP BY kind", (since,),
|
||||
# Counts per event kind over the window (each = distinct visitor-days, by dedup).
|
||||
kc_rows = conn.execute(
|
||||
"SELECT kind, COUNT(*) n FROM events WHERE day>=date('now',?) GROUP BY kind", (since,)
|
||||
).fetchall()
|
||||
shares = {k: 0 for k in ("share_ub", "copy_source", "native_share", "source_click")}
|
||||
shares.update({r["kind"]: r["n"] for r in share_rows})
|
||||
kc = {r["kind"]: r["n"] for r in kc_rows}
|
||||
|
||||
shares = {k: kc.get(k, 0) for k in ("share_ub", "copy_source", "native_share", "source_click")}
|
||||
|
||||
summary_views = kc.get("summary_viewed", 0)
|
||||
source_clicks = kc.get("source_click", 0)
|
||||
funnel = {
|
||||
"summary_viewed": summary_views,
|
||||
"source_click": source_clicks,
|
||||
"full_story": kc.get("full_story", 0),
|
||||
"source_rate": round(100 * source_clicks / summary_views) if summary_views else 0,
|
||||
}
|
||||
emotional_mix = {
|
||||
"not_today": kc.get("not_today", 0),
|
||||
"less_like_this": kc.get("less_like_this", 0),
|
||||
"hide_topic": kc.get("hide_topic", 0),
|
||||
}
|
||||
paywall = {
|
||||
"paywall_replace": kc.get("paywall_replace", 0),
|
||||
"paywalled_source_open": kc.get("paywalled_source_open", 0),
|
||||
}
|
||||
replace = {"used": kc.get("replace_used", 0), "none": kc.get("replace_none", 0)}
|
||||
|
||||
# Accounts — aggregate counts only (no emails, no per-user listing).
|
||||
accounts = {
|
||||
"total": scalar("SELECT COUNT(*) FROM users"),
|
||||
"new_today": scalar("SELECT COUNT(*) FROM users WHERE date(created_at)=date('now')"),
|
||||
"new_7d": scalar("SELECT COUNT(*) FROM users WHERE created_at>=date('now','-7 days')"),
|
||||
"new_30d": scalar("SELECT COUNT(*) FROM users WHERE created_at>=date('now',?)", (since,)),
|
||||
"active_7d": scalar("SELECT COUNT(DISTINCT user_id) FROM sessions WHERE last_seen_at>=date('now','-7 days')"),
|
||||
}
|
||||
|
||||
# Returning-visitor buckets by distinct active days in the window.
|
||||
bucket_rows = conn.execute(
|
||||
"SELECT CASE WHEN d>=8 THEN '8+' WHEN d>=4 THEN '4-7' WHEN d>=2 THEN '2-3' ELSE 'new' END b, "
|
||||
"COUNT(*) n FROM (SELECT visitor_hash, COUNT(DISTINCT day) d FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?) GROUP BY visitor_hash) GROUP BY b",
|
||||
(since,),
|
||||
).fetchall()
|
||||
buckets = {r["b"]: r["n"] for r in bucket_rows}
|
||||
retention = {k: buckets.get(k, 0) for k in ("new", "2-3", "4-7", "8+")}
|
||||
|
||||
daily = [dict(r) for r in conn.execute(
|
||||
"SELECT day, SUM(kind='open') AS opens, SUM(kind='visit') AS visits FROM events "
|
||||
"WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
"SELECT day, SUM(kind IN ('open','summary_viewed')) AS opens, SUM(kind='visit') AS visits "
|
||||
"FROM events WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
)]
|
||||
|
||||
return {
|
||||
@@ -258,6 +299,12 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"visitors": visitors,
|
||||
"returning": loyalty.get("returning", 0),
|
||||
"once": loyalty.get("once", 0),
|
||||
"retention": retention,
|
||||
"accounts": accounts,
|
||||
"funnel": funnel,
|
||||
"emotional_mix": emotional_mix,
|
||||
"paywall": paywall,
|
||||
"replace": replace,
|
||||
"top_articles": top_articles,
|
||||
"top_groupings": top_groupings,
|
||||
"top_topics": top_topics,
|
||||
|
||||
@@ -162,6 +162,15 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None)
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<script>
|
||||
(function(){{
|
||||
try{{
|
||||
var v=localStorage.getItem('goodnews:visitor')||'';
|
||||
var b=JSON.stringify({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
||||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events', new Blob([b],{{type:'application/json'}}));
|
||||
}}catch(e){{}}
|
||||
}})();
|
||||
</script>
|
||||
{src_click}
|
||||
<script>
|
||||
(function(){{
|
||||
|
||||
Reference in New Issue
Block a user