From a5595146c4852b557ab6b80fc20e2335228afa65 Mon Sep 17 00:00:00 2001 From: Ada Date: Sun, 19 Apr 2026 20:42:59 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=84=EF=B8=8F=20feat:=20persist=20autho?= =?UTF-8?q?r=20+=20tags=20on=20/admin/notes;=20expose=20revision=20history?= =?UTF-8?q?=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the notes backend so admin UIs can set author identity and tags directly (previously only the markdown-sync path wrote them), and surface the append-only revision ledger that was already being written but not read. Changes: - Schema v11 → v12: add author_name + author_kind columns to lab_notes; v_lab_notes view exposes them; v7 + v8 table rebuilds carry them so fresh installs and legacy migrations both end up consistent - POST /admin/notes now accepts author_name, author_kind, tags; COALESCE preserves existing author fields when omitted so markdown-sync stays authoritative; tags use a delete+reinsert on lab_note_tags when a tags array is provided - Hard rule: type="tail" requires a non-empty author_name (rejected 400 otherwise) - GET /admin/notes and GET /admin/notes/:slug now return author_name, author_kind, and a tags[] array - New: GET /admin/notes/:slug/revisions lists the ledger (revision_num, source, intent, auth_type, content_hash, length, created_at) with current/published pointers on the envelope - New: GET /admin/notes/:slug/revisions/:revId returns the full revision content for diff/restore UI to build on All 41 tests pass. Co-authored-by: Sage --- src/db/migrateLabNotes.ts | 13 ++- src/routes/adminRoutes.ts | 185 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 191 insertions(+), 7 deletions(-) diff --git a/src/db/migrateLabNotes.ts b/src/db/migrateLabNotes.ts index 1506c52..984bf71 100644 --- a/src/db/migrateLabNotes.ts +++ b/src/db/migrateLabNotes.ts @@ -17,7 +17,7 @@ import crypto from "crypto"; * If content appears stale or "reverts", check views FIRST. */ -export const LAB_NOTES_SCHEMA_VERSION = 11; +export const LAB_NOTES_SCHEMA_VERSION = 12; function setLabNotesSchemaVersion(db: Database.Database, version: number) { const cur = db @@ -136,6 +136,8 @@ export function migrateLabNotesSchema( // Authors { name: "author", ddl: "TEXT" }, { name: "ai_author", ddl: "TEXT" }, + { name: "author_name", ddl: "TEXT" }, + { name: "author_kind", ddl: "TEXT" }, // Translation metadata { name: "source_locale", ddl: "TEXT" }, @@ -478,6 +480,8 @@ export function migrateLabNotesSchema( author TEXT, ai_author TEXT, + author_name TEXT, + author_kind TEXT, source_locale TEXT, translation_status TEXT NOT NULL DEFAULT 'original', @@ -504,6 +508,7 @@ export function migrateLabNotesSchema( tags_json, dept, card_style, status, published_at, author, ai_author, + author_name, author_kind, source_locale, translation_status, translation_provider, translation_version, source_updated_at, translation_meta_json, content_html, @@ -539,6 +544,8 @@ export function migrateLabNotesSchema( author, ai_author, + NULL, + NULL, source_locale, COALESCE(NULLIF(translation_status,''), 'original'), @@ -642,7 +649,9 @@ export function migrateLabNotesSchema( n.published_at, n.author, n.ai_author, - + n.author_name, + n.author_kind, + n.source_locale, n.translation_status, n.translation_provider, diff --git a/src/routes/adminRoutes.ts b/src/routes/adminRoutes.ts index 2a3b041..1a9bf6c 100644 --- a/src/routes/adminRoutes.ts +++ b/src/routes/adminRoutes.ts @@ -32,6 +32,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { department_id, shadow_density, coherence_score, safer_landing, read_time_minutes, published_at, + author_name, author_kind, created_at, updated_at FROM v_lab_notes ORDER BY @@ -40,9 +41,25 @@ export function registerAdminRoutes(app: any, db: Database.Database) { updated_at DESC ` ) - .all(); + .all() as Array<{ id: string } & Record>; - return res.json(rows); + const tagRows = db + .prepare(`SELECT note_id, tag FROM lab_note_tags`) + .all() as Array<{ note_id: string; tag: string }>; + + const tagsByNoteId = new Map(); + for (const { note_id, tag } of tagRows) { + const list = tagsByNoteId.get(note_id) ?? []; + list.push(tag); + tagsByNoteId.set(note_id, list); + } + + const withTags = rows.map((r) => ({ + ...r, + tags: tagsByNoteId.get(r.id) ?? [], + })); + + return res.json(withTags); } catch (e: any) { return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); } @@ -70,22 +87,129 @@ export function registerAdminRoutes(app: any, db: Database.Database) { department_id, shadow_density, coherence_score, safer_landing, read_time_minutes, published_at, + author_name, author_kind, created_at, updated_at FROM v_lab_notes WHERE slug = ? AND locale = ? LIMIT 1 ` ) - .get(slug, locale); + .get(slug, locale) as ({ id: string } & Record) | undefined; if (!row) return res.status(404).json({ error: "Not found" }); - return res.json(row); + + const tagRows = db + .prepare(`SELECT tag FROM lab_note_tags WHERE note_id = ?`) + .all(row.id) as Array<{ tag: string }>; + + return res.json({ ...row, tags: tagRows.map((t) => t.tag) }); } catch (e: any) { return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); } }); + // --------------------------------------------------------------------------- + // Admin: list revisions for a note (protected) + // GET /admin/notes/:slug/revisions?locale=en + // --------------------------------------------------------------------------- + app.get("/admin/notes/:slug/revisions", requireAdmin, (req: Request, res: Response) => { + try { + const slug = String(req.params.slug ?? "").trim(); + const locale = normalizeLocale(String(req.query.locale ?? "en")); + if (!slug) return res.status(400).json({ error: "slug is required" }); + + const note = db + .prepare(`SELECT id, current_revision_id, published_revision_id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`) + .get(slug, locale) as + | { id: string; current_revision_id: string | null; published_revision_id: string | null } + | undefined; + + if (!note) return res.status(404).json({ error: "Not found" }); + + const rows = db + .prepare(` + SELECT + id, + revision_num, + supersedes_revision_id, + content_hash, + length(content_markdown) AS content_length, + source, + intent, + auth_type, + created_at + FROM lab_note_revisions + WHERE note_id = ? + ORDER BY revision_num DESC + `) + .all(note.id) as Array<{ + id: string; + revision_num: number; + supersedes_revision_id: string | null; + content_hash: string; + content_length: number; + source: string; + intent: string; + auth_type: string; + created_at: string; + }>; + + return res.json({ + note_id: note.id, + current_revision_id: note.current_revision_id, + published_revision_id: note.published_revision_id, + revisions: rows, + }); + } catch (e: any) { + return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); + } + }); + + // --------------------------------------------------------------------------- + // Admin: single revision detail (protected) + // GET /admin/notes/:slug/revisions/:revId?locale=en + // --------------------------------------------------------------------------- + app.get("/admin/notes/:slug/revisions/:revId", requireAdmin, (req: Request, res: Response) => { + try { + const slug = String(req.params.slug ?? "").trim(); + const revId = String(req.params.revId ?? "").trim(); + const locale = normalizeLocale(String(req.query.locale ?? "en")); + if (!slug || !revId) return res.status(400).json({ error: "slug and revId are required" }); + + const note = db + .prepare(`SELECT id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`) + .get(slug, locale) as { id: string } | undefined; + if (!note) return res.status(404).json({ error: "Not found" }); + + const row = db + .prepare(` + SELECT + id, + note_id, + revision_num, + supersedes_revision_id, + frontmatter_json, + content_markdown, + content_hash, + schema_version, + source, + intent, + intent_version, + auth_type, + created_at + FROM lab_note_revisions + WHERE id = ? AND note_id = ? + LIMIT 1 + `) + .get(revId, note.id); + + if (!row) return res.status(404).json({ error: "Revision not found" }); + return res.json(row); + } catch (e: any) { + return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) }); + } + }); // --------------------------------------------------------------------------- // Admin: upsert Lab Note (protected) @@ -113,11 +237,36 @@ export function registerAdminRoutes(app: any, db: Database.Database) { type, status, dept, + author_name, + author_kind, + tags, } = req.body ?? {}; if (!title) return res.status(400).json({ error: "title is required" }); if (!slug) return res.status(400).json({ error: "slug is required" }); + const incomingAuthorName = + typeof author_name === "string" && author_name.trim() ? author_name.trim() : null; + const incomingAuthorKind = + author_kind === "human" || author_kind === "ai" || author_kind === "hybrid" + ? author_kind + : null; + + const normalizedTags: string[] = Array.isArray(tags) + ? Array.from( + new Set( + tags + .map((t) => (typeof t === "string" ? t.trim() : "")) + .filter((t) => t.length > 0) + ) + ) + : []; + + // Tails must have an author_name. + if (String(type ?? "").trim() === "tail" && !incomingAuthorName) { + return res.status(400).json({ error: "author_name is required for tails" }); + } + const incomingSubtitle = typeof subtitle === "string" ? subtitle.trim() : null; @@ -173,6 +322,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { category, excerpt, summary, department_id, shadow_density, coherence_score, safer_landing, read_time_minutes, published_at, + author_name, author_kind, updated_at ) VALUES ( @@ -181,6 +331,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) { ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now') ) ON CONFLICT(slug, locale) DO UPDATE SET @@ -199,6 +350,8 @@ export function registerAdminRoutes(app: any, db: Database.Database) { safer_landing=excluded.safer_landing, read_time_minutes=excluded.read_time_minutes, published_at=excluded.published_at, + author_name = COALESCE(excluded.author_name, lab_notes.author_name), + author_kind = COALESCE(excluded.author_kind, lab_notes.author_kind), updated_at=excluded.updated_at `).run( noteId, @@ -222,9 +375,23 @@ export function registerAdminRoutes(app: any, db: Database.Database) { safer_landing ? 1 : 0, read_time_minutes ?? 5, - normalizedPublishedAt + normalizedPublishedAt, + + incomingAuthorName, + incomingAuthorKind ); + // 1b) Replace tags (only when caller provided a tags array) + if (Array.isArray(tags)) { + db.prepare(`DELETE FROM lab_note_tags WHERE note_id = ?`).run(noteId); + if (normalizedTags.length > 0) { + const insertTag = db.prepare( + `INSERT OR IGNORE INTO lab_note_tags (note_id, tag) VALUES (?, ?)` + ); + for (const t of normalizedTags) insertTag.run(noteId, t); + } + } + // 2) Clear legacy HTML so nothing can “win” accidentally db.prepare(`UPDATE lab_notes SET content_html = NULL WHERE id = ?`).run(noteId); @@ -268,6 +435,14 @@ export function registerAdminRoutes(app: any, db: Database.Database) { excerpt: excerpt || "", category: category || "Uncategorized", read_time_minutes: read_time_minutes ?? 5, + author: + incomingAuthorName || incomingAuthorKind + ? { + ...(incomingAuthorKind ? { kind: incomingAuthorKind } : {}), + ...(incomingAuthorName ? { name: incomingAuthorName } : {}), + } + : undefined, + tags: Array.isArray(tags) ? normalizedTags : undefined, }; const canonical = `${JSON.stringify(frontmatter)}\n---\n${bodyMarkdown}`;