syntaxai/tdd.md · main · src / d21_handlers_admin.ts
// c21 — handlers: CRUD on sxdoc-backed pages + posts.
//
// Composes:
// c13_database listDocuments / loadDocument / saveDocument / deleteDocument
// c32_session getViewer (admin gate)
// c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument)
// c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form)
// c31_admin_validation validateEditForm (form → typed input)
// c51_render_admin shell rendering
//
// Routes (mounted in c21_app.ts):
// GET /admin
// GET /admin/new
// POST /admin/new
// GET /admin/edit/:type/:slug
// POST /admin/edit/:type/:slug
// POST /admin/delete/:type/:slug
//
// Auth: any non-admin signed-in viewer → 403 wall (matches the legacy
// /edit handler). Anonymous → 401 login wall.
import { ADMIN_USERNAME } from "./a31_site_config.ts";
import {
listDocuments,
loadDocument,
saveDocument,
deleteDocument,
} from "./c13_database.ts";
import { getViewer } from "./b32_session.ts";
import { htmlToSx } from "./a31_sxdoc_parse.ts";
import { validateEditForm } from "./a31_admin_validation.ts";
import { htmlResponse } from "./b51_render_layout.ts";
import {
renderAdminList,
renderAdminEdit,
renderAdminLoginWall,
renderAdminNonAdminWall,
} from "./b51_render_admin.ts";
const wantsJson = (req: Request): boolean =>
(req.headers.get("accept") ?? "").includes("application/json");
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
},
});
// ─── auth gate ───────────────────────────────────────────────────────────
interface AuthOk { ok: true; viewer: string; }
interface AuthDenied { ok: false; response: Response; }
type AuthResult = AuthOk | AuthDenied;
const requireAdmin = async (req: Request): Promise<AuthResult> => {
const viewer = await getViewer(req);
if (!viewer) {
const html = await renderAdminLoginWall();
return { ok: false, response: htmlResponse(html, 401) };
}
if (viewer !== ADMIN_USERNAME) {
const html = await renderAdminNonAdminWall(viewer);
return { ok: false, response: htmlResponse(html, 403) };
}
return { ok: true, viewer };
};
// FormData → string-record adapter. The validator lives in c31 and
// stays browser-agnostic by taking plain string fields.
const formToRecord = async (req: Request): Promise<Record<string, string>> => {
const fd = await req.formData();
const out: Record<string, string> = {};
for (const [k, v] of fd.entries()) out[k] = String(v);
return out;
};
// ─── handlers ────────────────────────────────────────────────────────────
export const adminListHandler = async (req: Request): Promise<Response> => {
const auth = await requireAdmin(req);
if (!auth.ok) return auth.response;
const documents = listDocuments();
const html = await renderAdminList(documents);
return htmlResponse(html);
};
export const adminNewHandler = async (req: Request): Promise<Response> => {
const auth = await requireAdmin(req);
if (!auth.ok) return auth.response;
const json = wantsJson(req);
if (req.method === "POST") {
const form = await formToRecord(req);
const v = validateEditForm(form);
if (!v.ok) {
if (json) return jsonResponse({ ok: false, error: v.error }, 400);
const html = await renderAdminEdit({
mode: "new",
title: form.title ?? "",
slug: form.slug ?? "",
type: form.type === "post" ? "post" : "page",
doc: htmlToSx(form.html ?? ""),
status: form.status === "draft" ? "draft" : "published",
primaryTag: (form.primary_tag ?? "").trim() || null,
error: v.error,
});
return htmlResponse(html, 400);
}
if (loadDocument(v.data.slug, v.data.type)) {
const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
if (json) return jsonResponse({ ok: false, error: err }, 409);
const html = await renderAdminEdit({
mode: "new",
title: v.data.title,
slug: v.data.slug,
type: v.data.type,
doc: htmlToSx(v.data.html),
status: v.data.status,
primaryTag: v.data.primaryTag,
error: err,
});
return htmlResponse(html, 409);
}
saveDocument({
slug: v.data.slug,
type: v.data.type,
title: v.data.title,
doc: htmlToSx(v.data.html),
status: v.data.status,
primaryTag: v.data.primaryTag,
});
if (json) {
return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
}
return new Response(null, {
status: 303,
headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
});
}
// GET — empty form
const html = await renderAdminEdit({
mode: "new",
title: "",
slug: "",
type: "page",
doc: htmlToSx("<p>Hello, world.</p>"),
status: "published",
primaryTag: null,
});
return htmlResponse(html);
};
export const adminEditHandler = async (
req: Request & { params: { type: string; slug: string } },
): Promise<Response> => {
const auth = await requireAdmin(req);
if (!auth.ok) return auth.response;
const type = req.params.type === "post" ? "post" : "page";
if (req.params.type !== "page" && req.params.type !== "post") {
return new Response("invalid type", { status: 400 });
}
const slug = req.params.slug;
const existing = loadDocument(slug, type);
if (!existing) return new Response("not found", { status: 404 });
if (req.method === "POST") {
const form = await formToRecord(req);
const json = wantsJson(req);
const v = validateEditForm(form);
if (!v.ok) {
if (json) return jsonResponse({ ok: false, error: v.error }, 400);
const html = await renderAdminEdit({
mode: "edit",
title: form.title ?? existing.title,
slug: form.slug ?? slug,
type,
doc: htmlToSx(form.html ?? ""),
status: form.status === "draft" ? "draft" : "published",
primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag,
error: v.error,
});
return htmlResponse(html, 400);
}
// Rename (slug or type changed) — reject collision with another
// existing doc; otherwise delete the old key before saving the new one.
if (v.data.slug !== slug || v.data.type !== type) {
const collision = loadDocument(v.data.slug, v.data.type);
if (collision && collision.id !== existing.id) {
const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
if (json) return jsonResponse({ ok: false, error: err }, 409);
const html = await renderAdminEdit({
mode: "edit",
title: v.data.title,
slug: v.data.slug,
type: v.data.type,
doc: htmlToSx(v.data.html),
status: v.data.status,
primaryTag: v.data.primaryTag,
error: err,
});
return htmlResponse(html, 409);
}
deleteDocument(slug, type);
}
saveDocument({
slug: v.data.slug,
type: v.data.type,
title: v.data.title,
doc: htmlToSx(v.data.html),
status: v.data.status,
primaryTag: v.data.primaryTag,
});
if (json) {
return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
}
return new Response(null, {
status: 303,
headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
});
}
// GET — render the stored sxdoc directly; c51_render_admin computes
// the textarea HTML projection and embeds the JSON for client hydration.
const html = await renderAdminEdit({
mode: "edit",
title: existing.title,
slug: existing.slug,
type: existing.type,
doc: existing.doc,
status: existing.status,
primaryTag: existing.primaryTag,
});
return htmlResponse(html);
};
export const adminDeleteHandler = async (
req: Request & { params: { type: string; slug: string } },
): Promise<Response> => {
const auth = await requireAdmin(req);
if (!auth.ok) return auth.response;
if (req.method !== "POST") return new Response("POST only", { status: 405 });
const type = req.params.type === "post" ? "post" : "page";
if (req.params.type !== "page" && req.params.type !== "post") {
return new Response("invalid type", { status: 400 });
}
deleteDocument(req.params.slug, type);
return new Response(null, { status: 303, headers: { Location: "/admin" } });
};