Skip to content

Commit 5960cd9

Browse files
authored
Merge pull request #17 from AdaInTheLab/fix/admin-notes-token
FIX [SCMS] Stabilize admin API base URL resolution 🧿
2 parents cbe834f + 685035d commit 5960cd9

8 files changed

Lines changed: 293 additions & 150 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ SESSION_SECRET=your_session_secret_here
1717
ALLOWED_GITHUB_USERNAME=your_username_here
1818
ADMIN_DEV_BYPASS=true
1919

20-
LABNOTES_DIR=/home/humanpatternlab/lab-api/content/labnotes
20+
LABNOTES_DIR=content/labnotes
2121

2222

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { registerLabNotesRoutes } from "./routes/labNotesRoutes.js";
99
import { registerAdminRoutes } from "./routes/adminRoutes.js";
1010
import OpenApiValidator from "express-openapi-validator";
1111
import { registerOpenApiRoutes } from "./routes/openapiRoutes.js";
12+
import { registerAdminTokensRoutes } from "./routes/adminTokensRoutes.js";
1213
import fs from "node:fs";
1314
import path from "node:path";
1415
import { env } from "./env.js";
@@ -228,6 +229,7 @@ export function createApp() {
228229
registerHealthRoutes(api, dbPath);
229230
registerAdminRoutes(api, db);
230231
registerLabNotesRoutes(api, db);
232+
registerAdminTokensRoutes(app, db);
231233

232234
// MOUNT THE ROUTER (this is what makes routes actually exist)
233235
app.use("/", api); // ✅ canonical

src/db.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import Database from "better-sqlite3";
33
import path from "path";
44
import { fileURLToPath } from "url";
55
import crypto from "crypto";
6-
import { marked } from "marked";
76
import { env } from "./env.js";
87
import { nowIso, sha256Hex } from './lib/helpers.js';
98
import { migrateLabNotesSchema, LAB_NOTES_SCHEMA_VERSION } from "./db/migrateLabNotes.js";

src/lib/markdownBlocks.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export function expandMascotBlocks(md: string): string {
2+
// :::carmel ... :::
3+
return md.replace(
4+
/(^|\n):::carmel\s*\n([\s\S]*?)\n:::(?=\n|$)/g,
5+
(_m, lead, body) => {
6+
const text = body.trim();
7+
// Escape HTML so users can't inject tags inside the block
8+
const safe = text
9+
.replace(/&/g, "&")
10+
.replace(/</g, "&lt;")
11+
.replace(/>/g, "&gt;");
12+
13+
return `${lead}<div class="carmel-callout">
14+
<div class="carmel-callout-title">😼 <strong>Carmel calls this:</strong></div>
15+
<div class="carmel-callout-body">“${safe}”</div>
16+
</div>`;
17+
}
18+
);
19+
}

src/mappers/labNotesMapper.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const ALLOWED_NOTE_TYPES: ReadonlySet<LabNoteType> = new Set([
2727
"weather",
2828
]);
2929

30+
marked.setOptions({
31+
gfm: true,
32+
breaks: false, // ✅ strict
33+
});
34+
3035
/**
3136
* deriveStatus
3237
* If status is missing/invalid, infer from publish timestamp.

src/routes/adminRoutes.ts

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
import type { Request, Response } from "express";
33
import type Database from "better-sqlite3";
44
import { randomUUID } from "node:crypto";
5+
import { marked } from "marked";
56
import passport, { requireAdmin, isGithubOAuthEnabled } from "../auth.js";
67
import { syncLabNotesFromFs } from "../services/syncLabNotesFromFs.js";
78
import { normalizeLocale, sha256Hex } from "../lib/helpers.js";
89

10+
marked.setOptions({
11+
gfm: true,
12+
breaks: false, // ✅ strict
13+
});
14+
15+
916
export function registerAdminRoutes(app: any, db: Database.Database) {
1017
// Must match your UI origin exactly (no trailing slash)
1118
const UI_BASE_URL = process.env.UI_BASE_URL ?? "http://localhost:8001";
@@ -121,13 +128,32 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
121128

122129
// ✅ Resolve canonical noteId by (slug, locale) to make upserts stable
123130
const existing = db
124-
.prepare("SELECT id FROM lab_notes WHERE slug = ? AND locale = ?")
125-
.get(slug, noteLocale) as { id: string } | undefined;
131+
.prepare("SELECT id, department_id, dept, type FROM lab_notes WHERE slug = ? AND locale = ?")
132+
.get(slug, noteLocale) as
133+
| { id: string; department_id: string | null; dept: string | null; type: string | null }
134+
| undefined;
126135

127136
// If the row already exists, prefer its id over any incoming id.
128137
// This prevents “identity drift” where (slug, locale) updates a different id.
129138
const noteId = existing?.id ?? id ?? randomUUID();
130139

140+
const incomingDepartment =
141+
typeof department_id === "string" && department_id.trim() ? department_id.trim() : null;
142+
143+
const incomingDept =
144+
typeof dept === "string" && dept.trim() ? dept.trim() : null;
145+
146+
// Preserve existing if not provided, else default for brand-new notes
147+
const resolvedDepartment =
148+
incomingDepartment ?? existing?.department_id ?? "SCMS";
149+
150+
const resolvedDept =
151+
incomingDept ?? existing?.dept ?? null;
152+
153+
// Type is identity-ish too; preserve if missing
154+
const resolvedType =
155+
(typeof type === "string" && type.trim() ? type.trim() : null) ?? existing?.type ?? "labnote";
156+
131157
const tx = db.transaction(() => {
132158
// 1) Upsert metadata row (NO content_html writes, NO content_markdown column)
133159
db.prepare(`
@@ -151,11 +177,11 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
151177
title=excluded.title,
152178
type=excluded.type,
153179
status=excluded.status,
154-
dept=excluded.dept,
180+
dept = COALESCE(excluded.dept, lab_notes.dept),
155181
category=excluded.category,
156182
excerpt=excluded.excerpt,
157183
summary=excluded.summary,
158-
department_id=excluded.department_id,
184+
department_id = COALESCE(excluded.department_id, lab_notes.department_id),
159185
shadow_density=excluded.shadow_density,
160186
coherence_score=excluded.coherence_score,
161187
safer_landing=excluded.safer_landing,
@@ -176,7 +202,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
176202
excerpt || "",
177203
summary || "",
178204

179-
department_id || "SCMS",
205+
incomingDepartment,
180206
shadow_density ?? 0,
181207
coherence_score ?? 1.0,
182208
safer_landing ? 1 : 0,
@@ -200,7 +226,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
200226
const nextRev = (revRow?.maxRev ?? 0) + 1;
201227

202228
// 4) Create revision row (ledger truth)
203-
const revisionId = crypto.randomUUID();
229+
const revisionId = randomUUID();
204230

205231
const prevPointer = db
206232
.prepare(`
@@ -219,7 +245,7 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
219245
status: noteStatus,
220246
published: normalizedPublishedAt ?? undefined,
221247
dept: dept ?? null,
222-
department_id: department_id || "SCMS",
248+
department_id: resolvedDepartment,
223249
shadow_density: shadow_density ?? 0,
224250
coherence_score: coherence_score ?? 1.0,
225251
safer_landing: Boolean(safer_landing),
@@ -342,57 +368,63 @@ export function registerAdminRoutes(app: any, db: Database.Database) {
342368
// ---------------------------------------------------------------------------
343369
// Admin: Publish Lab Note (protected)
344370
// ---------------------------------------------------------------------------
345-
app.post("/admin/notes/:id/publish", requireAdmin, (req: Request, res: Response) => {
371+
// Admin: Publish by slug + locale
372+
app.post("/admin/notes/:slug/publish", requireAdmin, (req: Request, res: Response) => {
346373
try {
347-
const id = String(req.params.id ?? "").trim();
348-
if (!id) return res.status(400).json({ error: "id is required" });
374+
const slug = String(req.params.slug ?? "").trim();
375+
const locale = normalizeLocale(String(req.query.locale ?? "en"));
376+
if (!slug) return res.status(400).json({ error: "slug is required" });
349377

350378
const nowDate = new Date().toISOString().slice(0, 10);
351379

352-
const result = db
353-
.prepare(
354-
`
355-
UPDATE lab_notes
356-
SET
357-
status = 'published',
358-
published_at = COALESCE(published_at, ?),
359-
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
360-
WHERE id = ?
361-
`
362-
)
363-
.run(nowDate, id);
380+
const row = db
381+
.prepare(`SELECT id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`)
382+
.get(slug, locale) as { id: string } | undefined;
364383

365-
if (result.changes === 0) return res.status(404).json({ error: "Not found" });
366-
return res.json({ ok: true, id, status: "published" });
384+
if (!row) return res.status(404).json({ error: "Not found" });
385+
386+
db.prepare(`
387+
UPDATE lab_notes
388+
SET
389+
status = 'published',
390+
published_revision_id = current_revision_id,
391+
published_at = COALESCE(published_at, ?),
392+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
393+
WHERE id = ?
394+
`).run(nowDate, row.id);
395+
396+
return res.json({ ok: true, slug, locale, id: row.id, status: "published" });
367397
} catch (e: any) {
368398
return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) });
369399
}
370400
});
371401

372-
373402
// ---------------------------------------------------------------------------
374403
// Admin: Un-publish Lab Note (protected)
375404
// ---------------------------------------------------------------------------
376-
app.post("/admin/notes/:id/unpublish", requireAdmin, (req: Request, res: Response) => {
405+
app.post("/admin/notes/:slug/unpublish", requireAdmin, (req: Request, res: Response) => {
377406
try {
378-
const id = String(req.params.id ?? "").trim();
379-
if (!id) return res.status(400).json({ error: "id is required" });
407+
const slug = String(req.params.slug ?? "").trim();
408+
const locale = normalizeLocale(String(req.query.locale ?? "en"));
409+
if (!slug) return res.status(400).json({ error: "slug is required" });
380410

381-
const result = db
382-
.prepare(
383-
`
384-
UPDATE lab_notes
385-
SET
386-
status = 'draft',
387-
published_at = NULL,
388-
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
389-
WHERE id = ?
390-
`
391-
)
392-
.run(id);
411+
const row = db
412+
.prepare(`SELECT id FROM lab_notes WHERE slug = ? AND locale = ? LIMIT 1`)
413+
.get(slug, locale) as { id: string } | undefined;
414+
415+
if (!row) return res.status(404).json({ error: "Not found" });
393416

394-
if (result.changes === 0) return res.status(404).json({ error: "Not found" });
395-
return res.json({ ok: true, id, status: "draft" });
417+
db.prepare(`
418+
UPDATE lab_notes
419+
SET
420+
status = 'draft',
421+
published_revision_id = NULL,
422+
published_at = NULL,
423+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
424+
WHERE id = ?
425+
`).run(row.id);
426+
427+
return res.json({ ok: true, slug, locale, id: row.id, status: "draft" });
396428
} catch (e: any) {
397429
return res.status(500).json({ error: "Database error", details: String(e?.message ?? e) });
398430
}

0 commit comments

Comments
 (0)