From 84fd61bf3f4ec20c5938eeb8fc016ab6309e903f Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 8 Jun 2026 14:23:56 -0400 Subject: [PATCH] In-site feedback reply (plain-text v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply to a reader from the admin inbox instead of a mailto. Per Codex: keep v1 plain text (no rich editor — defers the user's bold/bullets ask as a fast-follow). * DB: feedback_replies table (feedback_id, user_id, message, sent_to, sent_at), created on the live DB. * email_send.send_feedback_reply: plain-text "Re: Your Upbeat Bytes feedback" with a quoted context block, no analytics/account details. * API: POST /api/admin/feedback/{id}/reply — admin-gated, requires the feedback exists (404) and has a contact_email (400), trims+caps the message; sends via SMTP and only records the reply on success (502 on send failure so the UI keeps the draft); marks the item read. Feedback list now includes each item's replies. * Frontend: inline composer (Send/Cancel, sending state, error keeps draft) + reply thread under the message; Reply only shows when there's an address, else "No reply address". Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/admin/+page.svelte | 79 +++++++++++++++++++++++++- goodnews/api.py | 52 ++++++++++++++++- goodnews/db.py | 10 ++++ goodnews/email_send.py | 15 +++++ tests/test_feedback.py | 32 +++++++++++ 5 files changed, 185 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index aa5c10f..896747a 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -130,6 +130,29 @@ } } + // In-site reply (plain text, sent via SMTP; only stored on successful send). + let replyingId = $state(null); + let replyText = $state(''); + let replyBusy = $state(false); + let replyErr = $state(''); + function openReply(f) { replyingId = f.id; replyText = ''; replyErr = ''; } + function cancelReply() { replyingId = null; replyText = ''; replyErr = ''; } + async function sendReply(f) { + const msg = replyText.trim(); + if (!msg || replyBusy) return; + replyBusy = true; replyErr = ''; + try { + const res = await postJSON(`/api/admin/feedback/${f.id}/reply`, { message: msg }); + f.replies = [...(f.replies || []), res.reply]; + f.read_at = f.read_at || res.reply.sent_at; // server marked it read + replyingId = null; replyText = ''; + } catch (e) { + replyErr = e?.message || 'Could not send — the draft is kept.'; + } finally { + replyBusy = false; + } + } + const SHARE_LABEL = { share_ub: 'Copied UB link', native_share: 'Native share', @@ -396,10 +419,40 @@ {#if !f.read_at}{/if} {f.category} {fdate(f.created_at)} - {#if f.contact_email}Reply ↩{:else}anonymous{/if} + {#if f.contact_email}{f.contact_email}{:else}anonymous{/if}

{f.message}

+ + {#if f.replies?.length} +
+ {#each f.replies as r (r.id)} +
+ ↪ Replied · {fwhen(r.sent_at)} +

{r.message}

+
+ {/each} +
+ {/if} + + {#if replyingId === f.id} +
+ + {#if replyErr}

{replyErr}

{/if} +
+ + +
+
+ {/if} +
+ {#if f.contact_email && replyingId !== f.id} + + {:else if !f.contact_email} + No reply address + {/if}
@@ -536,9 +589,31 @@ .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 .when { color: var(--muted); } - .fhead .reply { color: var(--accent-deep); margin-left: auto; } + .fhead .addr { color: var(--accent-deep); margin-left: auto; } .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; } + + /* Reply thread + composer */ + .thread { margin: 10px 0 0; display: flex; flex-direction: column; gap: 8px; } + .rep { border-left: 2px solid var(--accent-soft); padding: 2px 0 2px 12px; } + .rep .rl { font-size: 0.72rem; color: var(--muted); } + .rep .repmsg { margin: 2px 0 0; font-size: 0.9rem; color: var(--ink); white-space: pre-wrap; } + .composer { margin: 10px 0 0; } + .composer textarea { + width: 100%; box-sizing: border-box; font: inherit; font-size: 0.9rem; + padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; + background: var(--bg); color: var(--ink); resize: vertical; + } + .composer textarea:focus { outline: none; border-color: var(--accent); } + .cerr { margin: 6px 0 0; color: #9a3b3b; font-size: 0.82rem; } + .cbtns { display: flex; align-items: center; gap: 12px; margin-top: 8px; } + .csend { + font: inherit; font-size: 0.82rem; font-weight: 600; background: var(--accent); color: #fff; + border: none; border-radius: 999px; padding: 7px 18px; cursor: pointer; + } + .csend:hover { background: var(--accent-deep); } + .csend:disabled { opacity: 0.55; cursor: default; } + .noreply { color: var(--muted); font-size: 0.76rem; font-style: italic; } .factions { display: flex; gap: 14px; margin-top: 9px; } .factions .act { background: none; border: none; padding: 0; cursor: pointer; diff --git a/goodnews/api.py b/goodnews/api.py index ac242a3..5e79ebb 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -381,6 +381,10 @@ class FeedbackReadBody(BaseModel): read: bool = True +class FeedbackReplyBody(BaseModel): + message: str = "" + + class SourceActiveBody(BaseModel): active: bool = True @@ -799,7 +803,21 @@ def create_app() -> FastAPI: "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 200" ).fetchall() - return [dict(r) for r in rows] + items = [dict(r) for r in rows] + if items: + ids = [it["id"] for it in items] + ph = ",".join("?" * len(ids)) + reps = conn.execute( + f"SELECT id, feedback_id, message, sent_to, sent_at FROM feedback_replies " + f"WHERE feedback_id IN ({ph}) ORDER BY sent_at", + ids, + ).fetchall() + by_fid: dict = {} + for r in reps: + by_fid.setdefault(r["feedback_id"], []).append(dict(r)) + for it in items: + it["replies"] = by_fid.get(it["id"], []) + return items @app.post("/api/admin/feedback/{fid}/read") def admin_feedback_read(fid: int, body: FeedbackReadBody, request: Request) -> dict: @@ -822,6 +840,38 @@ def create_app() -> FastAPI: conn.commit() return {"ok": True} + @app.post("/api/admin/feedback/{fid}/reply") + def admin_feedback_reply(fid: int, body: FeedbackReplyBody, request: Request) -> dict: + message = (body.message or "").strip()[:4000] + if not message: + raise HTTPException(status_code=422, detail="Reply message is required.") + with get_conn() as conn: + admin = _require_admin(conn, request) + fb = conn.execute("SELECT contact_email, message FROM feedback WHERE id = ?", (fid,)).fetchone() + if not fb: + raise HTTPException(status_code=404, detail="feedback not found") + if not fb["contact_email"]: + raise HTTPException(status_code=400, detail="No reply address for this feedback.") + # Send first — only record a reply that actually went out, so the UI + # can keep the draft on failure. + try: + email_send.send_feedback_reply(fb["contact_email"], message, fb["message"]) + except Exception as exc: # noqa: BLE001 — surface any SMTP failure to the admin + raise HTTPException(status_code=502, detail=f"Could not send the reply: {exc}") + cur = conn.execute( + "INSERT INTO feedback_replies (feedback_id, user_id, message, sent_to) VALUES (?, ?, ?, ?)", + (fid, admin["id"], message, fb["contact_email"]), + ) + conn.execute( + "UPDATE feedback SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP) WHERE id = ?", (fid,) + ) + conn.commit() + reply = conn.execute( + "SELECT id, feedback_id, message, sent_to, sent_at FROM feedback_replies WHERE id = ?", + (cur.lastrowid,), + ).fetchone() + return {"ok": True, "reply": dict(reply)} + @app.post("/api/admin/sources/{sid}/active") def admin_source_active(sid: int, body: SourceActiveBody, request: Request) -> dict: # Pause/resume a feed. Pausing only stops future polling — its already- diff --git a/goodnews/db.py b/goodnews/db.py index d688ad3..01379c1 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -240,6 +240,16 @@ CREATE TABLE IF NOT EXISTS feedback ( read_at TEXT ); CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at); + +CREATE TABLE IF NOT EXISTS feedback_replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feedback_id INTEGER NOT NULL REFERENCES feedback(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + message TEXT NOT NULL, + sent_to TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_feedback_replies_fid ON feedback_replies(feedback_id); """ diff --git a/goodnews/email_send.py b/goodnews/email_send.py index 84d54c9..37e6318 100644 --- a/goodnews/email_send.py +++ b/goodnews/email_send.py @@ -63,6 +63,21 @@ def send_feedback(to: str, category: str, message: str, contact: str | None, who send_email(to, subject, text) +def send_feedback_reply(to: str, reply_message: str, original_message: str) -> None: + """Reply to a reader's feedback from the admin inbox (plain text). Quotes + their original note for context; exposes no analytics/account details.""" + subject = "Re: Your Upbeat Bytes feedback" + quoted = "\n".join("> " + line for line in (original_message or "").splitlines()) + text = ( + f"{reply_message}\n\n" + "—\n" + "In reply to your note to Upbeat Bytes:\n" + f"{quoted}\n\n" + "Thanks for reaching out.\n— Upbeat Bytes\n" + ) + send_email(to, subject, text) + + def send_magic_link(to: str, link: str) -> None: """Send a calm, single-purpose sign-in email.""" subject = "Your Upbeat Bytes sign-in link" diff --git a/tests/test_feedback.py b/tests/test_feedback.py index e2c288d..7c6a4ae 100644 --- a/tests/test_feedback.py +++ b/tests/test_feedback.py @@ -88,9 +88,41 @@ def test_feedback_admin_actions_gated(tmp_path, monkeypatch): 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 + assert anon.post("/api/admin/feedback/1/reply", json={"message": "hi"}).status_code == 401 def test_feedback_actions_404_on_missing_id(tmp_path, monkeypatch): tc = _admin_client(tmp_path, monkeypatch) assert tc.post("/api/admin/feedback/9999/read", json={"read": True}).status_code == 404 assert tc.delete("/api/admin/feedback/9999").status_code == 404 + + +def test_feedback_reply_sends_stores_marks_read(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) + sent = {} + monkeypatch.setattr(api.email_send, "send_feedback_reply", + lambda to, msg, orig: sent.update(to=to, msg=msg, orig=orig)) + TestClient(app).post("/api/feedback", json={"message": "is there an app?", "email": "reader@x.com", "visitor": "v"}) + tc = TestClient(app); link = {} + monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, l: link.update(l=l)) + tc.post("/api/auth/email/start", json={"email": "boss@x.com"}) + tc.post("/api/auth/email/verify", json={"token": link["l"].split("token=")[1]}) + fid = tc.get("/api/admin/feedback").json()[0]["id"] + r = tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "Yes — a companion app is coming."}) + assert r.status_code == 200 + assert sent["to"] == "reader@x.com" and "companion app" in sent["msg"] and "is there an app?" in sent["orig"] + fb = tc.get("/api/admin/feedback").json()[0] + assert fb["read_at"] is not None # marked read on reply + assert len(fb["replies"]) == 1 and fb["replies"][0]["sent_to"] == "reader@x.com" + + +def test_feedback_reply_requires_address_and_message(tmp_path, monkeypatch): + tc = _admin_client(tmp_path, monkeypatch) # posts anonymous feedback (no email) + monkeypatch.setattr("goodnews.email_send.send_feedback_reply", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send"))) + fid = tc.get("/api/admin/feedback").json()[0]["id"] + assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": ""}).status_code == 422 + assert tc.post(f"/api/admin/feedback/{fid}/reply", json={"message": "hi"}).status_code == 400 # no address + assert tc.post("/api/admin/feedback/9999/reply", json={"message": "hi"}).status_code == 404 + import goodnews.api as api # already reloaded by _admin_client's app