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:
jay
2026-06-08 13:33:24 -04:00
parent 13722f04a8
commit ecaca35977
6 changed files with 141 additions and 28 deletions
+3 -3
View File
@@ -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) == []