704 lines
24 KiB
TypeScript
704 lines
24 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 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<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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function singletonGet(
|
|
table: string,
|
|
fields: Record<string, import('./registry').FieldDef>,
|
|
): Promise<Response> {
|
|
const sql = getDb();
|
|
const rows = await sql.unsafe<Array<Record<string, unknown>>>(
|
|
`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<string, import('./registry').FieldDef>,
|
|
): Promise<Response> {
|
|
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<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: 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<string, import('./registry').FieldDef>,
|
|
): Promise<Record<string, unknown>[]> {
|
|
const order = sortable ? 'ORDER BY sort_order' : '';
|
|
const rows = await getDb().unsafe<Array<Record<string, unknown>>>(
|
|
`SELECT * FROM ${table} ${order}`,
|
|
);
|
|
return rows.map((r) => ({ id: r['id'], ...rowToApi(r, fields) }));
|
|
}
|
|
|
|
async function listGetAllResponse(
|
|
table: string,
|
|
sortable: boolean,
|
|
fields: Record<string, import('./registry').FieldDef>,
|
|
): Promise<Response> {
|
|
return json(await 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 sql = getDb();
|
|
|
|
if (sortable) {
|
|
const maxRows = await sql.unsafe<Array<{ max: number }>>(
|
|
`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<never>[],
|
|
);
|
|
await 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 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<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: 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<string, import('./registry').FieldDef>,
|
|
): Promise<Response> {
|
|
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<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 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<Response> {
|
|
const sql = getDb();
|
|
const sectionOrder = def.sortable ? 'ORDER BY sort_order' : '';
|
|
const sections = await sql.unsafe<Array<Record<string, unknown>>>(
|
|
`SELECT * FROM ${def.table} ${sectionOrder}`,
|
|
);
|
|
const children = await sql.unsafe<Array<Record<string, unknown>>>(
|
|
`SELECT * FROM ${def.childTable!} ORDER BY sort_order`,
|
|
);
|
|
|
|
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 sql = getDb();
|
|
|
|
if (def.sortable) {
|
|
const maxRows = await sql.unsafe<Array<{ max: number }>>(
|
|
`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<never>[],
|
|
);
|
|
await 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 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<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: 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<Response> {
|
|
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<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 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<Response> {
|
|
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<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 maxRows = await sql.unsafe<Array<{ max: number }>>(
|
|
`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<never>[],
|
|
);
|
|
await 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 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<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: 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<Response> {
|
|
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<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 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<Response> {
|
|
const nsCol = def.kvNamespaceCol!;
|
|
const keyCol = def.kvKeyCol!;
|
|
|
|
const rows = await getDb().unsafe<Array<Record<string, unknown>>>(
|
|
`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<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] = {};
|
|
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<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 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<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 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<Record<string, unknown>>;
|
|
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<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 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<Array<Record<string, unknown>>>(
|
|
`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);
|
|
}
|
|
}
|