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
+25 -2
View File
@@ -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: