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:
+25
-2
@@ -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:
|
||||
|
||||
+7
-1
@@ -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")
|
||||
|
||||
+6
-4
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user