/** * 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 type postgres from 'postgres'; 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 | null> { try { const text = await req.text(); if (!text.trim()) return null; return JSON.parse(text) as Record; } catch { return null; } } function parseId(segment: string): number | null { const n = parseInt(segment, 10); return isNaN(n) || n <= 0 ? null : n; } // --------------------------------------------------------------------------- // Singleton handlers // --------------------------------------------------------------------------- async function singletonGet( table: string, fields: Record, ): Promise { const sql = getDb(); const rows = await sql.unsafe>>( `SELECT * FROM ${table} WHERE id = 1`, ); const row = rows[0] ?? 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, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); const sql = getDb(); const existing = await sql.unsafe(`SELECT id FROM ${table} WHERE id = 1`); if (!existing[0]) return jsonError(`${table} not configured — run migration first`, 404); let params: Record; 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: setClause, values } = buildSetClause(params); const whereIdx = values.length + 1; await sql.unsafe(`UPDATE ${table} SET ${setClause} WHERE id = $${whereIdx}`, [...values, 1]); await touchLastModified(); return singletonGet(table, fields); } // --------------------------------------------------------------------------- // List helpers // --------------------------------------------------------------------------- async function listGetAll( table: string, sortable: boolean, fields: Record, ): Promise[]> { const order = sortable ? 'ORDER BY sort_order' : ''; const rows = await getDb().unsafe>>( `SELECT * FROM ${table} ${order}`, ); return rows.map((r) => ({ id: r['id'], ...rowToApi(r, fields) })); } async function listGetAllResponse( table: string, sortable: boolean, fields: Record, ): Promise { return json(await listGetAll(table, sortable, fields)); } async function listPost( req: Request, table: string, sortable: boolean, fields: Record, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); let params: Record; try { params = bodyToDb(body, fields); } catch (err) { return jsonError(err instanceof Error ? err.message : 'Validation error', 400); } const sql = getDb(); if (sortable) { const maxRows = await sql.unsafe>( `SELECT COALESCE(MAX(sort_order), -1) as max FROM ${table}`, ); params['sort_order'] = (maxRows[0]?.max ?? -1) + 1; } const cols = Object.keys(params); const placeholders = cols.map((_, i) => `$${i + 1}`).join(', '); await sql.unsafe( `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders})`, Object.values(params) as postgres.ParameterOrJSON[], ); await touchLastModified(); return listGetAllResponse(table, sortable, fields); } async function listPut( req: Request, id: number, table: string, sortable: boolean, fields: Record, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); const sql = getDb(); const existing = await sql.unsafe(`SELECT id FROM ${table} WHERE id = $1`, [id]); if (!existing[0]) return jsonError('Record not found', 404); let params: Record; 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: setClause, values } = buildSetClause(params); const whereIdx = values.length + 1; await sql.unsafe(`UPDATE ${table} SET ${setClause} WHERE id = $${whereIdx}`, [...values, id]); await touchLastModified(); return listGetAllResponse(table, sortable, fields); } async function listDelete( id: number, table: string, sortable: boolean, fields: Record, ): Promise { const sql = getDb(); const result = await sql.unsafe(`DELETE FROM ${table} WHERE id = $1 RETURNING id`, [id]); if (result.length === 0) return jsonError('Record not found', 404); await touchLastModified(); return listGetAllResponse(table, sortable, fields); } async function listReorder( req: Request, table: string, sortable: boolean, fields: Record, ): Promise { const body = await parseBody(req); if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400); const ids = body['ids'] as number[]; const sql = getDb(); for (let i = 0; i < ids.length; i++) { await sql.unsafe(`UPDATE ${table} SET sort_order = $1 WHERE id = $2`, [i, ids[i]]); } await touchLastModified(); return listGetAllResponse(table, sortable, fields); } // --------------------------------------------------------------------------- // Hierarchical helpers // --------------------------------------------------------------------------- async function hierarchicalGetAll( def: import('./registry').ContentTypeDef, ): Promise { const sql = getDb(); const sectionOrder = def.sortable ? 'ORDER BY sort_order' : ''; const sections = await sql.unsafe>>( `SELECT * FROM ${def.table} ${sectionOrder}`, ); const children = await sql.unsafe>>( `SELECT * FROM ${def.childTable!} ORDER BY sort_order`, ); const childrenBySection = new Map[]>(); 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 { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); let params: Record; try { params = bodyToDb(body, def.fields); } catch (err) { return jsonError(err instanceof Error ? err.message : 'Validation error', 400); } const sql = getDb(); if (def.sortable) { const maxRows = await sql.unsafe>( `SELECT COALESCE(MAX(sort_order), -1) as max FROM ${def.table}`, ); params['sort_order'] = (maxRows[0]?.max ?? -1) + 1; } const cols = Object.keys(params); const placeholders = cols.map((_, i) => `$${i + 1}`).join(', '); await sql.unsafe( `INSERT INTO ${def.table} (${cols.join(', ')}) VALUES (${placeholders})`, Object.values(params) as postgres.ParameterOrJSON[], ); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalPutSection( req: Request, id: number, def: import('./registry').ContentTypeDef, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); const sql = getDb(); const existing = await sql.unsafe(`SELECT id FROM ${def.table} WHERE id = $1`, [id]); if (!existing[0]) return jsonError('Section not found', 404); let params: Record; 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: setClause, values } = buildSetClause(params); const whereIdx = values.length + 1; await sql.unsafe( `UPDATE ${def.table} SET ${setClause} WHERE id = $${whereIdx}`, [...values, id], ); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalDeleteSection( id: number, def: import('./registry').ContentTypeDef, ): Promise { const sql = getDb(); await sql.unsafe(`DELETE FROM ${def.childTable!} WHERE ${def.childForeignKey!} = $1`, [id]); const result = await sql.unsafe(`DELETE FROM ${def.table} WHERE id = $1 RETURNING id`, [id]); if (result.length === 0) return jsonError('Section not found', 404); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalReorderSections( req: Request, def: import('./registry').ContentTypeDef, ): Promise { const body = await parseBody(req); if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400); const ids = body['ids'] as number[]; const sql = getDb(); for (let i = 0; i < ids.length; i++) { await sql.unsafe(`UPDATE ${def.table} SET sort_order = $1 WHERE id = $2`, [i, ids[i]]); } await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalPostEntry( req: Request, sectionId: number, def: import('./registry').ContentTypeDef, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); const sql = getDb(); const section = await sql.unsafe(`SELECT id FROM ${def.table} WHERE id = $1`, [sectionId]); if (!section[0]) return jsonError('Section not found', 404); let params: Record; try { params = bodyToDb(body, def.childFields ?? {}); } catch (err) { return jsonError(err instanceof Error ? err.message : 'Validation error', 400); } params[def.childForeignKey!] = sectionId; const maxRows = await sql.unsafe>( `SELECT COALESCE(MAX(sort_order), -1) as max FROM ${def.childTable!} WHERE ${def.childForeignKey!} = $1`, [sectionId], ); params['sort_order'] = (maxRows[0]?.max ?? -1) + 1; const cols = Object.keys(params); const placeholders = cols.map((_, i) => `$${i + 1}`).join(', '); await sql.unsafe( `INSERT INTO ${def.childTable!} (${cols.join(', ')}) VALUES (${placeholders})`, Object.values(params) as postgres.ParameterOrJSON[], ); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalPutEntry( req: Request, id: number, def: import('./registry').ContentTypeDef, ): Promise { const body = await parseBody(req); if (!body) return jsonError('Invalid JSON body', 400); const sql = getDb(); const existing = await sql.unsafe(`SELECT id FROM ${def.childTable!} WHERE id = $1`, [id]); if (!existing[0]) return jsonError('Entry not found', 404); let params: Record; 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: setClause, values } = buildSetClause(params); const whereIdx = values.length + 1; await sql.unsafe( `UPDATE ${def.childTable!} SET ${setClause} WHERE id = $${whereIdx}`, [...values, id], ); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalDeleteEntry( id: number, def: import('./registry').ContentTypeDef, ): Promise { const result = await getDb().unsafe( `DELETE FROM ${def.childTable!} WHERE id = $1 RETURNING id`, [id], ); if (result.length === 0) return jsonError('Entry not found', 404); await touchLastModified(); return hierarchicalGetAll(def); } async function hierarchicalReorderEntries( req: Request, def: import('./registry').ContentTypeDef, ): Promise { const body = await parseBody(req); if (!body || !Array.isArray(body['ids'])) return jsonError('Missing ids array', 400); const ids = body['ids'] as number[]; const sql = getDb(); for (let i = 0; i < ids.length; i++) { await sql.unsafe(`UPDATE ${def.childTable!} SET sort_order = $1 WHERE id = $2`, [i, ids[i]]); } await touchLastModified(); return hierarchicalGetAll(def); } // --------------------------------------------------------------------------- // KV-store handlers // --------------------------------------------------------------------------- async function kvGetAll(def: import('./registry').ContentTypeDef): Promise { const nsCol = def.kvNamespaceCol!; const keyCol = def.kvKeyCol!; const rows = await getDb().unsafe>>( `SELECT * FROM ${def.table} ORDER BY ${nsCol}, ${keyCol}`, ); // 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> = {}; for (const row of rows) { const ns = row[nsCol] as string; const key = row[keyCol] as string; if (!result[ns]) result[ns] = {}; if (def.path === 'site-text') { result[ns][key] = row['value']; } else { 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); } } } return json(result); } async function kvPut(req: Request, def: import('./registry').ContentTypeDef): Promise { 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 sql = getDb(); if (def.path === 'link-values') { const score = body['score']; if (typeof score !== 'number') return jsonError('Missing score (number)', 400); await sql.unsafe( `INSERT INTO ${def.table} (${nsCol}, ${keyCol}, score, updated_at) VALUES ($1, $2, $3, now()) ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET score = EXCLUDED.score, updated_at = now()`, [ns, key, score], ); } else { const value = body['value']; if (typeof value !== 'string') return jsonError('Missing value (string)', 400); await sql.unsafe( `INSERT INTO ${def.table} (${nsCol}, ${keyCol}, value) VALUES ($1, $2, $3) ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET value = EXCLUDED.value`, [ns, key, value], ); } await touchLastModified(); return kvGetAll(def); } async function kvBatchPut( req: Request, def: import('./registry').ContentTypeDef, ): Promise { 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>; const sql = getDb(); 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); } await sql.unsafe( `INSERT INTO ${def.table} (${nsCol}, ${keyCol}, score, updated_at) VALUES ($1, $2, $3, now()) ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET score = EXCLUDED.score, updated_at = now()`, [ns, key, score], ); } await touchLastModified(); return kvGetAll(def); } if (!Array.isArray(body['entries'])) return jsonError('Missing entries array', 400); const entries = body['entries'] as Array>; const sql = getDb(); 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); } await sql.unsafe( `INSERT INTO ${def.table} (${nsCol}, ${keyCol}, value) VALUES ($1, $2, $3) ON CONFLICT(${nsCol}, ${keyCol}) DO UPDATE SET value = EXCLUDED.value`, [ns, key, value], ); } await touchLastModified(); return kvGetAll(def); } // --------------------------------------------------------------------------- // Main handler // --------------------------------------------------------------------------- export async function handleCms(req: Request, pathname: string): Promise { // 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 await 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 await 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 await 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 sql = getDb(); const rows = await sql.unsafe>>( `SELECT * FROM ${def.table} WHERE id = $1`, [id], ); const row = rows[0] ?? 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 await listDelete(id, def.table, def.sortable ?? false, def.fields); return jsonError('Method not allowed', 405); } return null; } // ── Hierarchical ─────────────────────────────────────────────────────── if (def.kind === 'hierarchical') { if (pathname === `${base}/entries/reorder` && req.method === 'PUT') { return await hierarchicalReorderEntries(req, def); } 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 await hierarchicalDeleteEntry(id, def); return jsonError('Method not allowed', 405); } if (pathname === `${base}/reorder` && req.method === 'PUT') { return await hierarchicalReorderSections(req, def); } 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); } if (pathname === base) { if (req.method === 'GET') return await 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 await 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); } }