NGU-Web/server/src/routes/admin.js

172 lines
5.7 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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;