172 lines
5.7 KiB
JavaScript
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;
|