v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
172
server/src/routes/admin.js
Normal file
172
server/src/routes/admin.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* ═══════════════════════════════════════════════════════════════
|
||||
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("admin"), 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue