syntaxai/tdd.md · main · src / d21_handlers_admin.ts

d21_handlers_admin.ts 255 lines · 8547 bytes raw
// 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" } });
};