/* ═══════════════════════════════════════════════════════════════ ADMIN ROUTES Everything under /api/admin requires a session. Writes require the 'admin' role; a 'viewer' can read and nothing else. No response from here is cacheable, and none of it should ever sit in a proxy. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; import { requireAuth, requireRole } from "../auth.js"; const admin = new Hono(); admin.use("*", requireAuth); const NO_STORE = { "Cache-Control": "no-store" }; const STATUSES = ["new", "read", "actioned", "archived", "spam"]; const MAX_LIMIT = 200; const NOTE_LIMIT = 2000; /* ── GET /api/admin/feedback ───────────────────────────────────── ?status=new one of STATUSES, or omitted for all ?type=broken feedback_type ?q=retreat substring of the message ?before=41 cursor: rows with a lower id than this ?limit=50 ───────────────────────────────────────────────────────────── */ admin.get("/feedback", (c) => { const db = c.get("db"); const { status, type, q, before, limit } = c.req.query(); const where = []; const params = []; if (STATUSES.includes(status)) { where.push("status = ?"); params.push(status); } if (type) { where.push("feedback_type = ?"); params.push(type.slice(0, 40)); } if (q) { where.push("message LIKE ?"); params.push(`%${q.slice(0, 100)}%`); } if (before && Number.isInteger(Number(before))) { where.push("id < ?"); params.push(Number(before)); } const take = Math.min(Number(limit) || 50, MAX_LIMIT); const rows = db .prepare( `SELECT id, created_at, feedback_type, message, name, email, page_path, section_id, status, admin_note FROM feedback ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY id DESC LIMIT ?`, ) .all(...params, take + 1); // one extra to detect a next page const hasMore = rows.length > take; const page = hasMore ? rows.slice(0, take) : rows; // Counts are unfiltered on purpose: the tabs should show what's // waiting overall, not what's left after the current filter. const counts = Object.fromEntries(STATUSES.map((s) => [s, 0])); for (const row of db .prepare("SELECT status, COUNT(*) AS n FROM feedback GROUP BY status") .all()) { counts[row.status] = row.n; } return c.json( { feedback: page, counts, nextCursor: hasMore ? page[page.length - 1].id : null, }, 200, NO_STORE, ); }); /* ── PATCH /api/admin/feedback/:id ─────────────────────────────── { status?, admin_note? } — either, both, partial. ───────────────────────────────────────────────────────────── */ admin.patch("/feedback/:id", requireRole("editor"), async (c) => { const id = Number(c.req.param("id")); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); let body; try { body = await c.req.json(); } catch { return c.json({ error: "Expected a JSON body." }, 400); } const sets = []; const params = []; if (body.status !== undefined) { if (!STATUSES.includes(body.status)) { return c.json({ error: "Unknown status." }, 422); } sets.push("status = ?"); params.push(body.status); } if (body.admin_note !== undefined) { const note = String(body.admin_note).trim().slice(0, NOTE_LIMIT); sets.push("admin_note = ?"); params.push(note || null); } if (sets.length === 0) return c.json({ error: "Nothing to change." }, 400); const result = db_update(c.get("db"), id, sets, params); if (!result) return c.json({ error: "No such feedback." }, 404); console.log(`feedback #${id} updated by ${c.get("user").email}`); return c.json({ feedback: result }, 200, NO_STORE); }); function db_update(db, id, sets, params) { const changed = db .prepare(`UPDATE feedback SET ${sets.join(", ")} WHERE id = ?`) .run(...params, id); if (changed.changes === 0) return null; return db .prepare( `SELECT id, created_at, feedback_type, message, name, email, page_path, section_id, status, admin_note FROM feedback WHERE id = ?`, ) .get(id); } /* ── DELETE /api/admin/feedback/:id ────────────────────────────── Actually gone. Marking something 'spam' is the reversible option and should be the habit; this is for the cases where the content itself shouldn't stay on disk. ───────────────────────────────────────────────────────────── */ admin.delete("/feedback/:id", requireRole("admin"), (c) => { const id = Number(c.req.param("id")); if (!Number.isInteger(id)) return c.json({ error: "Bad id." }, 400); const result = c .get("db") .prepare("DELETE FROM feedback WHERE id = ?") .run(id); if (result.changes === 0) return c.json({ error: "No such feedback." }, 404); console.log(`feedback #${id} deleted by ${c.get("user").email}`); return c.body(null, 204); }); export default admin;