diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index c5503f7..f774ec9 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import { getJSON } from '$lib/api.js'; + import { getJSON, postJSON, delJSON } from '$lib/api.js'; import { auth, refresh } from '$lib/auth.svelte.js'; let stats = $state(null); @@ -62,10 +62,37 @@ : sources ); - // Feedback inbox filter. - let fbCat = $state('all'); - let shownFeedback = $derived(fbCat === 'all' ? feedback : feedback.filter((f) => f.category === fbCat)); + // Feedback inbox: filter + read/unread + delete. + let fbCat = $state('all'); // 'all' | 'unread' | a category + let shownFeedback = $derived( + fbCat === 'all' ? feedback + : fbCat === 'unread' ? feedback.filter((f) => !f.read_at) + : feedback.filter((f) => f.category === fbCat) + ); let fbCats = $derived([...new Set(feedback.map((f) => f.category))]); + let unreadCount = $derived(feedback.filter((f) => !f.read_at).length); + + async function markRead(f, read) { + const prev = f.read_at; + f.read_at = read ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; // optimistic + feedback = [...feedback]; + try { + await postJSON(`/api/admin/feedback/${f.id}/read`, { read }); + } catch { + f.read_at = prev; // revert on failure + feedback = [...feedback]; + } + } + async function removeFeedback(f) { + if (!confirm('Delete this feedback message? This cannot be undone.')) return; + const snapshot = feedback; + feedback = feedback.filter((x) => x.id !== f.id); // optimistic + try { + await delJSON(`/api/admin/feedback/${f.id}`); + } catch { + feedback = snapshot; // revert on failure + } + } const SHARE_LABEL = { share_ub: 'Copied UB link', @@ -95,7 +122,7 @@ @@ -294,26 +321,34 @@ {:else if section === 'feedback'} -

Feedback {#if feedback.length}({feedback.length}){/if}

+

Feedback {#if feedback.length}({unreadCount} unread / {feedback.length}){/if}

{#if feedback.length}
+ {#each fbCats as cat (cat)} {/each}
- + {#if shownFeedback.length} + + {:else}

Nothing in this filter.

{/if} {:else}

No feedback yet.

{/if} {/if} {/if} @@ -419,10 +454,19 @@ .count { color: var(--muted); font-weight: 400; font-size: 0.9rem; } ul.fb { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } ul.fb li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } + ul.fb li.unread { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); } .fhead { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 0.78rem; } + .fhead .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } .fhead .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; } .fhead .when { color: var(--muted); } .fhead .reply { color: var(--accent-deep); margin-left: auto; } .fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; } .msg { margin: 0; color: var(--ink); font-size: 0.92rem; white-space: pre-wrap; } + .factions { display: flex; gap: 14px; margin-top: 9px; } + .factions .act { + background: none; border: none; padding: 0; cursor: pointer; + color: var(--muted); font-size: 0.76rem; border-bottom: 1px dotted var(--line); + } + .factions .act:hover { color: var(--accent-deep); border-bottom-color: var(--accent); } + .factions .act.del:hover { color: #9a3b3b; border-bottom-color: #9a3b3b; } diff --git a/goodnews/api.py b/goodnews/api.py index 53d765c..c50eb14 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -377,6 +377,10 @@ class FeedbackBody(BaseModel): hp: str | None = None # honeypot — bots fill it, humans don't +class FeedbackReadBody(BaseModel): + read: bool = True + + _FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"} # The only event kinds we record. All aggregate, non-personal. @@ -782,12 +786,31 @@ def create_app() -> FastAPI: 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, " + "SELECT f.id, f.category, f.message, f.contact_email, f.created_at, f.read_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" + "ORDER BY f.created_at DESC LIMIT 200" ).fetchall() return [dict(r) for r in rows] + @app.post("/api/admin/feedback/{fid}/read") + def admin_feedback_read(fid: int, body: FeedbackReadBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + if body.read: + conn.execute("UPDATE feedback SET read_at = CURRENT_TIMESTAMP WHERE id = ?", (fid,)) + else: + conn.execute("UPDATE feedback SET read_at = NULL WHERE id = ?", (fid,)) + conn.commit() + return {"ok": True, "read": body.read} + + @app.delete("/api/admin/feedback/{fid}") + def admin_feedback_delete(fid: int, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + conn.execute("DELETE FROM feedback WHERE id = ?", (fid,)) + conn.commit() + return {"ok": True} + @app.get("/api/admin/stats") def admin_stats(request: Request) -> dict: with get_conn() as conn: diff --git a/goodnews/db.py b/goodnews/db.py index f30b4a1..d688ad3 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -236,7 +236,8 @@ CREATE TABLE IF NOT EXISTS feedback ( 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 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + read_at TEXT ); CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at); """ @@ -304,3 +305,8 @@ def _migrate(conn: sqlite3.Connection) -> None: for column, decl in health_columns.items(): if column not in source_cols: conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}") + + # feedback.read_at (admin inbox read/unread) added later. + fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")} + if fb_cols and "read_at" not in fb_cols: + conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT") diff --git a/goodnews/queries.py b/goodnews/queries.py index 2f6071e..68f3bee 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -313,7 +313,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]: return [dict(r) for r in rows] -def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dict]: +def _attention(content: dict, sources: list[dict], feedback_unread: int) -> list[dict]: """The 'Attention Needed' strip: what an operator should look at, soft-toned (warn = act soon, info = worth a glance). Derived from the same data shown elsewhere, so it never disagrees with the detail sections.""" @@ -336,8 +336,8 @@ def _attention(content: dict, sources: list[dict], feedback_7d: int) -> list[dic if brief_size < 7: items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"}) - if feedback_7d: - items.append({"level": "info", "text": f"{feedback_7d} feedback message{n(feedback_7d)} in the last 7 days"}) + if feedback_unread: + items.append({"level": "info", "text": f"{feedback_unread} unread feedback message{n(feedback_unread)}"}) return items @@ -441,13 +441,15 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict: content = content_stats(conn) sources = source_health(conn) feedback_7d = scalar("SELECT COUNT(*) FROM feedback WHERE created_at >= datetime('now','-7 days')") + feedback_unread = scalar("SELECT COUNT(*) FROM feedback WHERE read_at IS NULL") return { "days": days, "content": content, "sources": sources, - "attention": _attention(content, sources, feedback_7d), + "attention": _attention(content, sources, feedback_unread), "feedback_7d": feedback_7d, + "feedback_unread": feedback_unread, "visitors": visitors, "returning": loyalty.get("returning", 0), "once": loyalty.get("once", 0), diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 6bfeb98..8c1507a 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -57,12 +57,12 @@ def test_attention_flags_resting_flagged_and_brief(): {"failures": 0, "review_flag": 1}, {"failures": 0, "review_flag": 0}, ] - items = queries._attention(content, sources, feedback_7d=2) + items = queries._attention(content, sources, feedback_unread=2) texts = " | ".join(i["text"] for i in items) assert "1 source backing off" in texts # one resting assert "1 source flagged" in texts # one flagged assert "brief has only 5" in texts # brief < 7 - assert "2 feedback" in texts # recent feedback + assert "2 unread feedback" in texts # unread feedback # 90/100 = 90% image coverage → no coverage warning assert "Image coverage" not in texts @@ -71,4 +71,4 @@ def test_attention_clear_when_healthy(): from goodnews import queries content = {"served": 100, "with_image": 95, "latest_brief_size": 7} sources = [{"failures": 0, "review_flag": 0}] - assert queries._attention(content, sources, feedback_7d=0) == [] + assert queries._attention(content, sources, feedback_unread=0) == [] diff --git a/tests/test_feedback.py b/tests/test_feedback.py index 360abfb..57c8ce6 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -50,3 +50,41 @@ def test_admin_feedback_gated(tmp_path, monkeypatch): stats = tc.get("/api/admin/stats").json() assert {"funnel", "accounts", "retention", "emotional_mix", "paywall", "replace"} <= set(stats) assert stats["accounts"]["total"] >= 1 + + +def _admin_client(tmp_path, monkeypatch): + app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com") + monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None) + TestClient(app).post("/api/feedback", json={"message": "hello there", "visitor": "v"}) + tc = TestClient(app); sent = {} + monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(link=link)) + tc.post("/api/auth/email/start", json={"email": "boss@x.com"}) + tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]}) + return tc + + +def test_feedback_read_unread_and_delete(tmp_path, monkeypatch): + tc = _admin_client(tmp_path, monkeypatch) + fb = tc.get("/api/admin/feedback").json() + fid = fb[0]["id"] + assert fb[0]["read_at"] is None # starts unread + assert tc.get("/api/admin/stats").json()["feedback_unread"] == 1 + + tc.post(f"/api/admin/feedback/{fid}/read", json={"read": True}) + assert tc.get("/api/admin/feedback").json()[0]["read_at"] is not None + assert tc.get("/api/admin/stats").json()["feedback_unread"] == 0 + + tc.post(f"/api/admin/feedback/{fid}/read", json={"read": False}) # back to unread + assert tc.get("/api/admin/stats").json()["feedback_unread"] == 1 + + assert tc.delete(f"/api/admin/feedback/{fid}").json() == {"ok": True} + assert tc.get("/api/admin/feedback").json() == [] + + +def test_feedback_admin_actions_gated(tmp_path, monkeypatch): + app, api, db = _app(tmp_path, monkeypatch, admin="boss@x.com") + monkeypatch.setattr(api.email_send, "send_feedback", lambda *a, **k: None) + TestClient(app).post("/api/feedback", json={"message": "hi", "visitor": "v"}) + anon = TestClient(app) + assert anon.post("/api/admin/feedback/1/read", json={"read": True}).status_code == 401 + assert anon.delete("/api/admin/feedback/1").status_code == 401