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:
+51
-1
@@ -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-
|
||||
|
||||
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user