Split server.mjs into modules: db, views, routes

server.mjs mixed SQL schema, HTML-rendering functions, and route
handling in one 476-line file. Split by concern:

- src/db.mjs — schema + migrations (openDb())
- src/views.mjs — pure render functions: layout, incidentForm,
  publicMarkdown/Incident/Detail, no db or network access
- src/style.mjs — the stylesheet, now genuinely shared instead of
  duplicated (receipts.mjs's /events page had its own near-copy,
  which is exactly what let their <nav> definitions drift out of
  sync a few commits ago)
- src/http-utils.mjs — form()/redirect(), used by more than one route
- src/routes/incidents.mjs — the private notebook + Git inbox +
  publish/unpublish, auth-gated once at the top instead of per-route
- src/routes/public-failures.mjs — the read-only /api/v1/public/failures*
  projection

server.mjs is now 79 lines of composition: open the db, build each
route module, wire the dispatch order, listen.

Also dropped the old POST /api/receipts endpoint and its `receipts`
table — dead since the v1 events API replaced it, already flagged as
"scheduled for removal" in the README.

Verified behavior is unchanged, not just "looks the same": full route
smoke test (401/200/404 in the right places, incident create→publish→
public-projection round trip, receipt ingestion), tests pass, and
before/after screenshots of every page are pixel-identical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-21 05:27:27 +02:00
co-authored by Claude Sonnet 5
parent 4b7b5a42d3
commit 0d825b4458
9 changed files with 606 additions and 451 deletions
+71
View File
@@ -0,0 +1,71 @@
// The read-only, unauthenticated projection of published incidents —
// consumed by goonk's /failures page at build and runtime. Never touches
// an incident until it has been explicitly published (see
// routes/incidents.mjs's publish/unpublish action); an unpublished
// incident is invisible here regardless of how much of it is filled in.
import { publicIncident, publicDetail, publicMarkdown } from '../views.mjs';
const jsonHeaders = {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=60',
};
export function createPublicFailuresRoutes(db) {
async function handle(req, res, path) {
if (req.method !== 'GET') return false;
if (path === '/api/v1/public/failures') {
const rows = db
.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC")
.all();
res.writeHead(200, jsonHeaders).end(
JSON.stringify({
schemaVersion: 1,
generatedAt: new Date().toISOString(),
failures: rows.map(publicIncident),
}),
);
return true;
}
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
if (md) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(md[1]));
if (!x) {
res.writeHead(404).end('Not found');
return true;
}
res
.writeHead(200, { ...jsonHeaders, 'content-type': 'text/markdown; charset=utf-8' })
.end(publicMarkdown(x));
return true;
}
const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/);
if (detail) {
const x = db
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
.get(decodeURIComponent(detail[1]));
if (!x) {
res
.writeHead(404, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
})
.end('{"error":"not_found"}');
return true;
}
res
.writeHead(200, jsonHeaders)
.end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) }));
return true;
}
return false;
}
return { handle };
}