Feedback inbox: read/unread + delete
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) == []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user