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:
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
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';
|
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
let stats = $state(null);
|
let stats = $state(null);
|
||||||
@@ -62,10 +62,37 @@
|
|||||||
: sources
|
: sources
|
||||||
);
|
);
|
||||||
|
|
||||||
// Feedback inbox filter.
|
// Feedback inbox: filter + read/unread + delete.
|
||||||
let fbCat = $state('all');
|
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||||
let shownFeedback = $derived(fbCat === 'all' ? feedback : feedback.filter((f) => f.category === fbCat));
|
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 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 = {
|
const SHARE_LABEL = {
|
||||||
share_ub: 'Copied UB link',
|
share_ub: 'Copied UB link',
|
||||||
@@ -95,7 +122,7 @@
|
|||||||
<nav class="tabs" aria-label="Dashboard sections">
|
<nav class="tabs" aria-label="Dashboard sections">
|
||||||
{#each TABS as t (t.key)}
|
{#each TABS as t (t.key)}
|
||||||
<a href={'?section=' + t.key} class:active={section === t.key}>
|
<a href={'?section=' + t.key} class:active={section === t.key}>
|
||||||
{t.label}{#if t.key === 'feedback' && feedback.length}<span class="badge">{feedback.length}</span>{/if}
|
{t.label}{#if t.key === 'feedback' && unreadCount}<span class="badge">{unreadCount}</span>{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -294,26 +321,34 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{:else if section === 'feedback'}
|
{:else if section === 'feedback'}
|
||||||
<h2>Feedback {#if feedback.length}<span class="count">({feedback.length})</span>{/if}</h2>
|
<h2>Feedback {#if feedback.length}<span class="count">({unreadCount} unread / {feedback.length})</span>{/if}</h2>
|
||||||
{#if feedback.length}
|
{#if feedback.length}
|
||||||
<div class="filterchips">
|
<div class="filterchips">
|
||||||
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
|
<button class="chip" class:on={fbCat === 'all'} onclick={() => (fbCat = 'all')}>All</button>
|
||||||
|
<button class="chip" class:on={fbCat === 'unread'} onclick={() => (fbCat = 'unread')}>Unread{#if unreadCount} ({unreadCount}){/if}</button>
|
||||||
{#each fbCats as cat (cat)}
|
{#each fbCats as cat (cat)}
|
||||||
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
|
<button class="chip" class:on={fbCat === cat} onclick={() => (fbCat = cat)}>{cat}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<ul class="fb">
|
{#if shownFeedback.length}
|
||||||
{#each shownFeedback as f (f.id)}
|
<ul class="fb">
|
||||||
<li>
|
{#each shownFeedback as f (f.id)}
|
||||||
<div class="fhead">
|
<li class:unread={!f.read_at}>
|
||||||
<span class="cat">{f.category}</span>
|
<div class="fhead">
|
||||||
<span class="when">{fdate(f.created_at)}</span>
|
{#if !f.read_at}<span class="dot" title="Unread"></span>{/if}
|
||||||
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
|
<span class="cat">{f.category}</span>
|
||||||
</div>
|
<span class="when">{fdate(f.created_at)}</span>
|
||||||
<p class="msg">{f.message}</p>
|
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
|
||||||
</li>
|
</div>
|
||||||
{/each}
|
<p class="msg">{f.message}</p>
|
||||||
</ul>
|
<div class="factions">
|
||||||
|
<button class="act" onclick={() => markRead(f, !f.read_at)}>{f.read_at ? 'Mark unread' : 'Mark read'}</button>
|
||||||
|
<button class="act del" onclick={() => removeFeedback(f)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}<p class="muted">Nothing in this filter.</p>{/if}
|
||||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -419,10 +454,19 @@
|
|||||||
.count { color: var(--muted); font-weight: 400; font-size: 0.9rem; }
|
.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 { 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 { 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 { 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 .cat { background: var(--accent-soft); color: var(--accent-deep); border-radius: 999px; padding: 2px 9px; text-transform: capitalize; }
|
||||||
.fhead .when { color: var(--muted); }
|
.fhead .when { color: var(--muted); }
|
||||||
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
.fhead .reply { color: var(--accent-deep); margin-left: auto; }
|
||||||
.fhead .anon { color: var(--muted); margin-left: auto; font-style: italic; }
|
.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; }
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+25
-2
@@ -377,6 +377,10 @@ class FeedbackBody(BaseModel):
|
|||||||
hp: str | None = None # honeypot — bots fill it, humans don't
|
hp: str | None = None # honeypot — bots fill it, humans don't
|
||||||
|
|
||||||
|
|
||||||
|
class FeedbackReadBody(BaseModel):
|
||||||
|
read: bool = True
|
||||||
|
|
||||||
|
|
||||||
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
_FEEDBACK_CATEGORIES = {"idea", "concern", "bug", "praise", "other"}
|
||||||
|
|
||||||
# The only event kinds we record. All aggregate, non-personal.
|
# The only event kinds we record. All aggregate, non-personal.
|
||||||
@@ -782,12 +786,31 @@ def create_app() -> FastAPI:
|
|||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
_require_admin(conn, request)
|
_require_admin(conn, request)
|
||||||
rows = conn.execute(
|
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 "
|
"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()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
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")
|
@app.get("/api/admin/stats")
|
||||||
def admin_stats(request: Request) -> dict:
|
def admin_stats(request: Request) -> dict:
|
||||||
with get_conn() as conn:
|
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,
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
visitor_hash TEXT NOT NULL DEFAULT '',
|
visitor_hash TEXT NOT NULL DEFAULT '',
|
||||||
day TEXT NOT NULL,
|
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);
|
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():
|
for column, decl in health_columns.items():
|
||||||
if column not in source_cols:
|
if column not in source_cols:
|
||||||
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
|
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]
|
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
|
"""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
|
(warn = act soon, info = worth a glance). Derived from the same data shown
|
||||||
elsewhere, so it never disagrees with the detail sections."""
|
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:
|
if brief_size < 7:
|
||||||
items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"})
|
items.append({"level": "info", "text": f"Today's brief has only {brief_size} item{n(brief_size)}"})
|
||||||
|
|
||||||
if feedback_7d:
|
if feedback_unread:
|
||||||
items.append({"level": "info", "text": f"{feedback_7d} feedback message{n(feedback_7d)} in the last 7 days"})
|
items.append({"level": "info", "text": f"{feedback_unread} unread feedback message{n(feedback_unread)}"})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -441,13 +441,15 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
|||||||
content = content_stats(conn)
|
content = content_stats(conn)
|
||||||
sources = source_health(conn)
|
sources = source_health(conn)
|
||||||
feedback_7d = scalar("SELECT COUNT(*) FROM feedback WHERE created_at >= datetime('now','-7 days')")
|
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 {
|
return {
|
||||||
"days": days,
|
"days": days,
|
||||||
"content": content,
|
"content": content,
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
"attention": _attention(content, sources, feedback_7d),
|
"attention": _attention(content, sources, feedback_unread),
|
||||||
"feedback_7d": feedback_7d,
|
"feedback_7d": feedback_7d,
|
||||||
|
"feedback_unread": feedback_unread,
|
||||||
"visitors": visitors,
|
"visitors": visitors,
|
||||||
"returning": loyalty.get("returning", 0),
|
"returning": loyalty.get("returning", 0),
|
||||||
"once": loyalty.get("once", 0),
|
"once": loyalty.get("once", 0),
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ def test_attention_flags_resting_flagged_and_brief():
|
|||||||
{"failures": 0, "review_flag": 1},
|
{"failures": 0, "review_flag": 1},
|
||||||
{"failures": 0, "review_flag": 0},
|
{"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)
|
texts = " | ".join(i["text"] for i in items)
|
||||||
assert "1 source backing off" in texts # one resting
|
assert "1 source backing off" in texts # one resting
|
||||||
assert "1 source flagged" in texts # one flagged
|
assert "1 source flagged" in texts # one flagged
|
||||||
assert "brief has only 5" in texts # brief < 7
|
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
|
# 90/100 = 90% image coverage → no coverage warning
|
||||||
assert "Image coverage" not in texts
|
assert "Image coverage" not in texts
|
||||||
|
|
||||||
@@ -71,4 +71,4 @@ def test_attention_clear_when_healthy():
|
|||||||
from goodnews import queries
|
from goodnews import queries
|
||||||
content = {"served": 100, "with_image": 95, "latest_brief_size": 7}
|
content = {"served": 100, "with_image": 95, "latest_brief_size": 7}
|
||||||
sources = [{"failures": 0, "review_flag": 0}]
|
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()
|
stats = tc.get("/api/admin/stats").json()
|
||||||
assert {"funnel", "accounts", "retention", "emotional_mix", "paywall", "replace"} <= set(stats)
|
assert {"funnel", "accounts", "retention", "emotional_mix", "paywall", "replace"} <= set(stats)
|
||||||
assert stats["accounts"]["total"] >= 1
|
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