In-site feedback reply (plain-text v1)

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) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 14:23:56 -04:00
parent 8cce3a2165
commit 84fd61bf3f
5 changed files with 185 additions and 3 deletions
+77 -2
View File
@@ -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}<span class="dot" title="Unread"></span>{/if}
<span class="cat">{f.category}</span>
<span class="when">{fdate(f.created_at)}</span>
{#if f.contact_email}<a class="reply" href={'mailto:' + f.contact_email}>Reply ↩</a>{:else}<span class="anon">anonymous</span>{/if}
{#if f.contact_email}<span class="addr">{f.contact_email}</span>{:else}<span class="anon">anonymous</span>{/if}
</div>
<p class="msg">{f.message}</p>
{#if f.replies?.length}
<div class="thread">
{#each f.replies as r (r.id)}
<div class="rep">
<span class="rl">↪ Replied · {fwhen(r.sent_at)}</span>
<p class="repmsg">{r.message}</p>
</div>
{/each}
</div>
{/if}
{#if replyingId === f.id}
<div class="composer">
<textarea bind:value={replyText} rows="4" placeholder="Write a reply…"></textarea>
{#if replyErr}<p class="cerr">{replyErr}</p>{/if}
<div class="cbtns">
<button class="csend" onclick={() => sendReply(f)} disabled={replyBusy || !replyText.trim()}>
{replyBusy ? 'Sending…' : 'Send reply'}
</button>
<button class="act" onclick={cancelReply}>Cancel</button>
</div>
</div>
{/if}
<div class="factions">
{#if f.contact_email && replyingId !== f.id}
<button class="act" onclick={() => openReply(f)}>Reply</button>
{:else if !f.contact_email}
<span class="noreply">No reply address</span>
{/if}
<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>
@@ -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;
+51 -1
View File
@@ -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-
+10
View File
@@ -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);
"""
+15
View File
@@ -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"
+32
View File
@@ -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