lilith-platform.live/codebase/@features/admin/backend-api/src/cms-handler.ts
2026-04-09 20:54:15 -07:00

659 lines
23 KiB
TypeScript

/**
* Generic CMS Handler
*
* Handles all CRUD operations for content types registered in the registry.
* Returns null if the pathname doesn't match any registered content type path.
*/
import { registry } from './registry';
import { rowToApi, bodyToDb, buildSetClause, snakeToCamel } from './serializer';
import { getDb, touchLastModified } from './db';
import { logger } from './logger';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const json = (data: unknown, status = 200): Response =>
new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
const jsonError = (message: string, status: number): Response =>
json({ error: message }, status);
async function parseBody(req: Request): Promise<Record<string, unknown> | null> {
try {
const text = await req.text();
if (!text.trim()) return null;
return JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
}
function parseId(segment: string): number | null {
const n = parseInt(segment, 10);
return isNaN(n) || n <= 0 ? null : n;
}
// ---------------------------------------------------------------------------
// Singleton handlers
// ---------------------------------------------------------------------------
function singletonGet(table: string, fields: Record<string, import('./registry').FieldDef>): Response {
const row = getDb().prepare(`SELECT * FROM ${table} WHERE id = 1`).get() as Record<string, unknown> | null;
if (!row) return jsonError(`${table} not configured`, 404);
return json({ id: row['id'], ...rowToApi(row, fields) });
}
async function singletonPut(
req: Request,
table: string,
fields: Record<string, import('./registry').FieldDef>,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const db = getDb();
const existing = db.prepare(`SELECT id FROM ${table} WHERE id = 1`).get();
if (!existing) return jsonError(`${table} not configured — run migration first`, 404);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, fields);
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
if (Object.keys(params).length === 0) return jsonError('No valid fields to update', 400);
const { sql, values } = buildSetClause(params);
db.prepare(`UPDATE ${table} SET ${sql} WHERE id = 1`).run(...values);
touchLastModified();
return singletonGet(table, fields);
}
// ---------------------------------------------------------------------------
// List helpers
// ---------------------------------------------------------------------------
function listGetAll(
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Record<string, unknown>[] {
const order = sortable ? 'ORDER BY sort_order' : '';
const rows = getDb().prepare(`SELECT * FROM ${table} ${order}`).all() as Record<string, unknown>[];
return rows.map((r) => ({ id: r['id'], ...rowToApi(r, fields) }));
}
function listGetAllResponse(
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Response {
return json(listGetAll(table, sortable, fields));
}
async function listPost(
req: Request,
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, fields);
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
const db = getDb();
if (sortable) {
const maxRow = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) as max FROM ${table}`).get() as { max: number };
params['sort_order'] = maxRow.max + 1;
}
const cols = Object.keys(params);
const placeholders = cols.map(() => '?').join(', ');
db.prepare(`INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`).run(...Object.values(params));
touchLastModified();
return listGetAllResponse(table, sortable, fields);
}
async function listPut(
req: Request,
id: number,
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const db = getDb();
const existing = db.prepare(`SELECT id FROM ${table} WHERE id = ?`).get(id);
if (!existing) return jsonError('Record not found', 404);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, fields);
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
if (Object.keys(params).length === 0) return jsonError('No valid fields to update', 400);
const { sql, values } = buildSetClause(params);
db.prepare(`UPDATE ${table} SET ${sql} WHERE id = ?`).run(...values, id);
touchLastModified();
return listGetAllResponse(table, sortable, fields);
}
function listDelete(
id: number,
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Response {
const result = getDb().prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
if (result.changes === 0) return jsonError('Record not found', 404);
touchLastModified();
return listGetAllResponse(table, sortable, fields);
}
async function listReorder(
req: Request,
table: string,
sortable: boolean,
fields: Record<string, import('./registry').FieldDef>,
): Promise<Response> {
const body = await parseBody(req);
if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400);
const ids = body['ids'] as number[];
const db = getDb();
const stmt = db.prepare(`UPDATE ${table} SET sort_order = ? WHERE id = ?`);
for (let i = 0; i < ids.length; i++) {
stmt.run(i, ids[i]);
}
touchLastModified();
return listGetAllResponse(table, sortable, fields);
}
// ---------------------------------------------------------------------------
// Hierarchical helpers
// ---------------------------------------------------------------------------
function hierarchicalGetAll(
def: import('./registry').ContentTypeDef,
): Response {
const db = getDb();
const sectionOrder = def.sortable ? 'ORDER BY sort_order' : '';
const sections = db.prepare(`SELECT * FROM ${def.table} ${sectionOrder}`).all() as Record<string, unknown>[];
const children = db.prepare(`SELECT * FROM ${def.childTable!} ORDER BY sort_order`).all() as Record<string, unknown>[];
const childrenBySection = new Map<number, Record<string, unknown>[]>();
for (const child of children) {
const sectionId = child[def.childForeignKey!] as number;
const list = childrenBySection.get(sectionId) ?? [];
list.push(child);
childrenBySection.set(sectionId, list);
}
return json(
sections.map((s) => ({
id: s['id'],
...rowToApi(s, def.fields),
entries: (childrenBySection.get(s['id'] as number) ?? []).map((c) => ({
id: c['id'],
...rowToApi(c, def.childFields ?? {}),
})),
})),
);
}
async function hierarchicalPostSection(
req: Request,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, def.fields);
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
const db = getDb();
if (def.sortable) {
const maxRow = db.prepare(`SELECT COALESCE(MAX(sort_order), -1) as max FROM ${def.table}`).get() as { max: number };
params['sort_order'] = maxRow.max + 1;
}
const cols = Object.keys(params);
const placeholders = cols.map(() => '?').join(', ');
db.prepare(`INSERT INTO ${def.table} (${cols.join(', ')}) VALUES (${placeholders})`).run(...Object.values(params));
touchLastModified();
return hierarchicalGetAll(def);
}
async function hierarchicalPutSection(
req: Request,
id: number,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const db = getDb();
const existing = db.prepare(`SELECT id FROM ${def.table} WHERE id = ?`).get(id);
if (!existing) return jsonError('Section not found', 404);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, def.fields);
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
if (Object.keys(params).length === 0) return jsonError('No valid fields to update', 400);
const { sql, values } = buildSetClause(params);
db.prepare(`UPDATE ${def.table} SET ${sql} WHERE id = ?`).run(...values, id);
touchLastModified();
return hierarchicalGetAll(def);
}
function hierarchicalDeleteSection(id: number, def: import('./registry').ContentTypeDef): Response {
const db = getDb();
// Delete child entries first (cascade)
db.prepare(`DELETE FROM ${def.childTable!} WHERE ${def.childForeignKey!} = ?`).run(id);
const result = db.prepare(`DELETE FROM ${def.table} WHERE id = ?`).run(id);
if (result.changes === 0) return jsonError('Section not found', 404);
touchLastModified();
return hierarchicalGetAll(def);
}
async function hierarchicalReorderSections(
req: Request,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400);
const ids = body['ids'] as number[];
const db = getDb();
const stmt = db.prepare(`UPDATE ${def.table} SET sort_order = ? WHERE id = ?`);
for (let i = 0; i < ids.length; i++) {
stmt.run(i, ids[i]);
}
touchLastModified();
return hierarchicalGetAll(def);
}
async function hierarchicalPostEntry(
req: Request,
sectionId: number,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const db = getDb();
const section = db.prepare(`SELECT id FROM ${def.table} WHERE id = ?`).get(sectionId);
if (!section) return jsonError('Section not found', 404);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, def.childFields ?? {});
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
params[def.childForeignKey!] = sectionId;
const maxRow = db.prepare(
`SELECT COALESCE(MAX(sort_order), -1) as max FROM ${def.childTable!} WHERE ${def.childForeignKey!} = ?`,
).get(sectionId) as { max: number };
params['sort_order'] = maxRow.max + 1;
const cols = Object.keys(params);
const placeholders = cols.map(() => '?').join(', ');
db.prepare(`INSERT INTO ${def.childTable!} (${cols.join(', ')}) VALUES (${placeholders})`).run(...Object.values(params));
touchLastModified();
return hierarchicalGetAll(def);
}
async function hierarchicalPutEntry(
req: Request,
id: number,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const db = getDb();
const existing = db.prepare(`SELECT id FROM ${def.childTable!} WHERE id = ?`).get(id);
if (!existing) return jsonError('Entry not found', 404);
let params: Record<string, unknown>;
try {
params = bodyToDb(body, def.childFields ?? {});
} catch (err) {
return jsonError(err instanceof Error ? err.message : 'Validation error', 400);
}
if (Object.keys(params).length === 0) return jsonError('No valid fields to update', 400);
const { sql, values } = buildSetClause(params);
db.prepare(`UPDATE ${def.childTable!} SET ${sql} WHERE id = ?`).run(...values, id);
touchLastModified();
return hierarchicalGetAll(def);
}
function hierarchicalDeleteEntry(id: number, def: import('./registry').ContentTypeDef): Response {
const result = getDb().prepare(`DELETE FROM ${def.childTable!} WHERE id = ?`).run(id);
if (result.changes === 0) return jsonError('Entry not found', 404);
touchLastModified();
return hierarchicalGetAll(def);
}
async function hierarchicalReorderEntries(
req: Request,
def: import('./registry').ContentTypeDef,
): Promise<Response> {
const body = await parseBody(req);
if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400);
const ids = body['ids'] as number[];
const db = getDb();
const stmt = db.prepare(`UPDATE ${def.childTable!} SET sort_order = ? WHERE id = ?`);
for (let i = 0; i < ids.length; i++) {
stmt.run(i, ids[i]);
}
touchLastModified();
return hierarchicalGetAll(def);
}
// ---------------------------------------------------------------------------
// KV-store handlers
// ---------------------------------------------------------------------------
function kvGetAll(def: import('./registry').ContentTypeDef): Response {
const nsCol = def.kvNamespaceCol!;
const keyCol = def.kvKeyCol!;
const rows = getDb()
.prepare(`SELECT * FROM ${def.table} ORDER BY ${nsCol}, ${keyCol}`)
.all() as Record<string, unknown>[];
// link-values: return as flat array of objects
if (def.path === 'link-values') {
return json(
rows.map((r) => ({
eventName: r[nsCol],
label: r[keyCol],
score: r['score'],
})),
);
}
// site-text: return nested namespace → key → value map
const result: Record<string, Record<string, unknown>> = {};
for (const row of rows) {
const ns = row[nsCol] as string;
const key = row[keyCol] as string;
if (!result[ns]) result[ns] = {};
// Serialize each value field
for (const [col, fieldDef] of Object.entries(def.fields)) {
if (col === keyCol || col === nsCol) continue;
result[ns][key] = fieldDef.type === 'number' ? (row[col] ?? null) : (row[col] ?? null);
}
// For site-text specifically, the value IS the value column
if (def.path === 'site-text') {
result[ns][key] = row['value'];
}
}
return json(result);
}
async function kvPut(req: Request, def: import('./registry').ContentTypeDef): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const nsCol = def.kvNamespaceCol!;
const keyCol = def.kvKeyCol!;
const ns = body[snakeToCamel(nsCol)] ?? body[nsCol];
const key = body[snakeToCamel(keyCol)] ?? body[keyCol];
if (typeof ns !== 'string' || typeof key !== 'string') {
return jsonError(`Missing ${nsCol} or ${keyCol}`, 400);
}
const db = getDb();
if (def.path === 'link-values') {
// link-values upsert: expects {eventName, label, score}
const score = body['score'];
if (typeof score !== 'number') return jsonError('Missing score (number)', 400);
db.prepare(
`INSERT INTO ${def.table} (${nsCol}, ${keyCol}, score, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET score = excluded.score, updated_at = datetime('now')`,
).run(ns, key, score);
} else {
// site-text upsert
const value = body['value'];
if (typeof value !== 'string') return jsonError('Missing value (string)', 400);
db.prepare(
`INSERT INTO ${def.table} (${nsCol}, ${keyCol}, value)
VALUES (?, ?, ?)
ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET value = excluded.value`,
).run(ns, key, value);
}
touchLastModified();
return kvGetAll(def);
}
async function kvBatchPut(req: Request, def: import('./registry').ContentTypeDef): Promise<Response> {
const body = await parseBody(req);
if (!body) return jsonError('Invalid JSON body', 400);
const nsCol = def.kvNamespaceCol!;
const keyCol = def.kvKeyCol!;
if (def.path === 'link-values') {
if (!Array.isArray(body['entries'])) return jsonError('Missing entries array', 400);
const entries = body['entries'] as Array<Record<string, unknown>>;
const db = getDb();
const stmt = db.prepare(
`INSERT INTO ${def.table} (${nsCol}, ${keyCol}, score, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET score = excluded.score, updated_at = datetime('now')`,
);
for (const entry of entries) {
const ns = entry['eventName'] ?? entry[nsCol];
const key = entry['label'] ?? entry[keyCol];
const score = entry['score'];
if (typeof ns !== 'string' || typeof key !== 'string' || typeof score !== 'number') {
return jsonError('Each entry requires eventName (string), label (string), score (number)', 400);
}
stmt.run(ns, key, score);
}
touchLastModified();
return kvGetAll(def);
}
// site-text batch
if (!Array.isArray(body['entries'])) return jsonError('Missing entries array', 400);
const entries = body['entries'] as Array<Record<string, unknown>>;
const db = getDb();
const stmt = db.prepare(
`INSERT INTO ${def.table} (${nsCol}, ${keyCol}, value)
VALUES (?, ?, ?)
ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET value = excluded.value`,
);
for (const entry of entries) {
const ns = entry[snakeToCamel(nsCol)] ?? entry[nsCol];
const key = entry[snakeToCamel(keyCol)] ?? entry[keyCol];
const value = entry['value'];
if (typeof ns !== 'string' || typeof key !== 'string' || typeof value !== 'string') {
return jsonError(`Each entry must have ${nsCol}, ${keyCol}, and value strings`, 400);
}
stmt.run(ns, key, value);
}
touchLastModified();
return kvGetAll(def);
}
// ---------------------------------------------------------------------------
// Main handler
// ---------------------------------------------------------------------------
export async function handleCms(req: Request, pathname: string): Promise<Response | null> {
// Find matching content type by path prefix
let matchedDef: import('./registry').ContentTypeDef | null = null;
let matchedPath: string | null = null;
for (const def of Object.values(registry)) {
const prefix = `/api/${def.path}`;
if (pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}?`)) {
matchedDef = def;
matchedPath = prefix;
break;
}
}
if (!matchedDef || !matchedPath) return null;
const def = matchedDef;
const base = matchedPath;
try {
// ── Singleton ──────────────────────────────────────────────────────────
if (def.kind === 'singleton') {
if (pathname !== base) return null;
if (req.method === 'GET') return singletonGet(def.table, def.fields);
if (req.method === 'PUT') return await singletonPut(req, def.table, def.fields);
return jsonError('Method not allowed', 405);
}
// ── KV-store ───────────────────────────────────────────────────────────
if (def.kind === 'kv-store') {
if (pathname === `${base}/batch` && req.method === 'PUT') {
return await kvBatchPut(req, def);
}
if (pathname === base) {
if (req.method === 'GET') return kvGetAll(def);
if (req.method === 'PUT') return await kvPut(req, def);
return jsonError('Method not allowed', 405);
}
return null;
}
// ── List ───────────────────────────────────────────────────────────────
if (def.kind === 'list') {
if (pathname === `${base}/reorder` && req.method === 'PUT') {
return await listReorder(req, def.table, def.sortable ?? false, def.fields);
}
if (pathname === base) {
if (req.method === 'GET') return listGetAllResponse(def.table, def.sortable ?? false, def.fields);
if (req.method === 'POST') return await listPost(req, def.table, def.sortable ?? false, def.fields);
return jsonError('Method not allowed', 405);
}
if (pathname.startsWith(`${base}/`)) {
const rest = pathname.slice(base.length + 1);
const id = parseId(rest);
if (!id) return null;
if (req.method === 'GET') {
const row = getDb().prepare(`SELECT * FROM ${def.table} WHERE id = ?`).get(id) as Record<string, unknown> | null;
if (!row) return jsonError('Record not found', 404);
return json({ id: row['id'], ...rowToApi(row, def.fields) });
}
if (req.method === 'PUT') return await listPut(req, id, def.table, def.sortable ?? false, def.fields);
if (req.method === 'DELETE') return listDelete(id, def.table, def.sortable ?? false, def.fields);
return jsonError('Method not allowed', 405);
}
return null;
}
// ── Hierarchical ───────────────────────────────────────────────────────
if (def.kind === 'hierarchical') {
// Entry reorder: PUT /api/:resource/entries/reorder
if (pathname === `${base}/entries/reorder` && req.method === 'PUT') {
return await hierarchicalReorderEntries(req, def);
}
// Entry update/delete: PUT|DELETE /api/:resource/entries/:id
if (pathname.startsWith(`${base}/entries/`)) {
const rest = pathname.slice(`${base}/entries/`.length);
const id = parseId(rest);
if (!id) return jsonError('Invalid entry ID', 400);
if (req.method === 'PUT') return await hierarchicalPutEntry(req, id, def);
if (req.method === 'DELETE') return hierarchicalDeleteEntry(id, def);
return jsonError('Method not allowed', 405);
}
// Section reorder: PUT /api/:resource/reorder
if (pathname === `${base}/reorder` && req.method === 'PUT') {
return await hierarchicalReorderSections(req, def);
}
// Create entry: POST /api/:resource/:id/entries
const entryCreateMatch = pathname.match(new RegExp(`^${base.replace('/', '\\/')}\\/([0-9]+)\\/entries$`));
if (entryCreateMatch && req.method === 'POST') {
const sectionId = parseId(entryCreateMatch[1]!);
if (!sectionId) return jsonError('Invalid section ID', 400);
return await hierarchicalPostEntry(req, sectionId, def);
}
// Section CRUD: GET|POST /api/:resource or PUT|DELETE /api/:resource/:id
if (pathname === base) {
if (req.method === 'GET') return hierarchicalGetAll(def);
if (req.method === 'POST') return await hierarchicalPostSection(req, def);
return jsonError('Method not allowed', 405);
}
if (pathname.startsWith(`${base}/`)) {
const rest = pathname.slice(base.length + 1);
const id = parseId(rest);
if (!id) return null;
if (req.method === 'PUT') return await hierarchicalPutSection(req, id, def);
if (req.method === 'DELETE') return hierarchicalDeleteSection(id, def);
return jsonError('Method not allowed', 405);
}
return null;
}
return null;
} catch (err) {
logger.error('CMS handler error', { pathname, error: String(err) });
return jsonError('Internal server error', 500);
}
}