Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
// Documents v4: Content retrieval and v4 import
|
|
import express from 'express';
|
|
import {
|
|
dbAll, dbGet, dbRun, isPostgres, safeJsonParse,
|
|
generateBaseId, apiLogger,
|
|
success, created, error, badRequest, notFound,
|
|
requireEditorAccess, ensureLanguageColumn,
|
|
} from './_helpers.js';
|
|
import { collectWidgetAtomInventory } from '../../../services/documents/widgetAtomInventory.js';
|
|
import { buildOrderClause } from '../../../services/orderClause.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/** GET /api/v3/documents/:documentId/content */
|
|
router.get('/documents/:documentId/content', async (req, res) => {
|
|
try {
|
|
const { documentId } = req.params;
|
|
const { registry_table_id } = req.query;
|
|
if (!registry_table_id) return badRequest(res, 'registry_table_id is required');
|
|
|
|
const docRow = await dbGet(
|
|
`SELECT data FROM table_rows WHERE id = ? AND table_id = ?`,
|
|
[documentId, registry_table_id]
|
|
);
|
|
if (!docRow) return notFound(res, 'Document not found');
|
|
|
|
const docData = safeJsonParse(docRow.data, {});
|
|
let tableId = docData.table_id;
|
|
|
|
// Auto-discover content table by slug/title pattern when table_id is missing
|
|
if (!tableId) {
|
|
const title = docData.title || docData.name || '';
|
|
const slugFull = title.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/gi, '').replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 60);
|
|
const slugShort = title.split(/[:\—–]/)[0].trim().toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/gi, '').replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
const candidates = [...new Set([slugShort, slugFull].filter(Boolean))];
|
|
|
|
for (const slug of candidates) {
|
|
const matchedTable = await dbGet(
|
|
`SELECT id FROM universal_tables WHERE name LIKE ? LIMIT 1`,
|
|
[`doc_${slug}%`]
|
|
);
|
|
if (matchedTable) {
|
|
tableId = matchedTable.id;
|
|
docData.table_id = tableId;
|
|
const updateSql = isPostgres()
|
|
? `UPDATE table_rows SET data = $1 WHERE id = $2`
|
|
: `UPDATE table_rows SET data = ? WHERE id = ?`;
|
|
await dbRun(updateSql, [JSON.stringify(docData), documentId]);
|
|
apiLogger.info(`[Documents v4] Auto-discovered content table ${tableId} for document ${documentId} (slug: ${slug})`);
|
|
break;
|
|
}
|
|
}
|
|
if (!tableId) return badRequest(res, 'Document has no associated table');
|
|
}
|
|
|
|
// NOTE: previously had a `ticket_ref` auto-migration block here. Removed
|
|
// 2026-04-27 — column dropped from DOCUMENT_TABLE_COLUMNS factory; embed
|
|
// tickets via widget_ref instead.
|
|
|
|
// Get all rows from document table
|
|
const contentOrderBy = buildOrderClause('order', {
|
|
dialect: isPostgres() ? 'pg' : 'sqlite', mode: 'coalesce', tieBreak: 'id',
|
|
});
|
|
const rows = await dbAll(
|
|
`SELECT id, base_id, data, created_at, updated_at FROM table_rows WHERE table_id = ? ORDER BY ${contentOrderBy}`,
|
|
[tableId]
|
|
);
|
|
|
|
const items = rows.map(row => ({
|
|
id: row.id, base_id: row.base_id,
|
|
...safeJsonParse(row.data, {}),
|
|
created_at: row.created_at, updated_at: row.updated_at
|
|
}));
|
|
|
|
// Build hierarchical structure based on level (h1 → h2 → h3 → text)
|
|
const buildTree = (items) => {
|
|
const result = [];
|
|
let currentH1 = null;
|
|
let currentH2 = null;
|
|
for (const item of items) {
|
|
const node = { ...item, children: [] };
|
|
switch (item.level) {
|
|
case 'h1':
|
|
result.push(node); currentH1 = node; currentH2 = null; break;
|
|
case 'h2':
|
|
if (currentH1) { currentH1.children.push(node); } else { result.push(node); }
|
|
currentH2 = node; break;
|
|
case 'h3': case 'text':
|
|
if (currentH2) { currentH2.children.push(node); }
|
|
else if (currentH1) { currentH1.children.push(node); }
|
|
else { result.push(node); } break;
|
|
case 'divider':
|
|
if (currentH2) { currentH2.children.push(node); }
|
|
else if (currentH1) { currentH1.children.push(node); }
|
|
else { result.push(node); } break;
|
|
default: result.push(node);
|
|
}
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ADR-0005 §C-13 — surface a per-doc widget-atom inventory so the
|
|
// client can render embedded-widget summaries (and locked-field
|
|
// affordances) without a second round-trip. Lossless: deleted target
|
|
// widgets/docs still appear with `missing: true`. Empty array when
|
|
// the document has no widget-atoms.
|
|
let widgetAtoms = [];
|
|
try {
|
|
widgetAtoms = await collectWidgetAtomInventory(items);
|
|
} catch (invErr) {
|
|
apiLogger.warn(
|
|
{ err: invErr.message, documentId },
|
|
'[Documents v4] widget-atom inventory failed (non-fatal, returning empty)'
|
|
);
|
|
widgetAtoms = [];
|
|
}
|
|
|
|
success(res, {
|
|
document: docData,
|
|
table_id: tableId,
|
|
items,
|
|
tree: buildTree(items),
|
|
count: items.length,
|
|
widget_atoms: widgetAtoms,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /documents/:documentId/content error:', err);
|
|
error(res, 'FETCH_DOCUMENT_CONTENT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/** POST /api/v3/documents/:documentId/import-v4 */
|
|
router.post('/documents/:documentId/import-v4', async (req, res) => {
|
|
try {
|
|
const { documentId } = req.params;
|
|
const { registry_table_id, sections } = req.body;
|
|
const userId = req.user?.id;
|
|
if (!registry_table_id || !sections || !Array.isArray(sections)) {
|
|
return badRequest(res, 'registry_table_id and sections array are required');
|
|
}
|
|
|
|
const importRegistryTable = await dbGet('SELECT project_id FROM universal_tables WHERE id = ?', [registry_table_id]);
|
|
if (importRegistryTable?.project_id) {
|
|
if (!(await requireEditorAccess(req, res, importRegistryTable.project_id))) return;
|
|
}
|
|
|
|
const docRow = await dbGet(
|
|
`SELECT data FROM table_rows WHERE id = ? AND table_id = ?`,
|
|
[documentId, registry_table_id]
|
|
);
|
|
if (!docRow) return notFound(res, 'Document not found');
|
|
|
|
const docData = safeJsonParse(docRow.data, {});
|
|
const tableId = docData.table_id;
|
|
if (!tableId) return badRequest(res, 'Document has no associated table');
|
|
|
|
// Detect which languages are used and ensure columns exist
|
|
const usedLanguages = new Set();
|
|
for (const section of sections) {
|
|
for (const key of Object.keys(section)) {
|
|
const match = key.match(/^content_([a-z]{2})$/);
|
|
if (match) usedLanguages.add(match[1]);
|
|
}
|
|
if (section.language && section.language !== 'en') usedLanguages.add(section.language);
|
|
}
|
|
usedLanguages.delete('en');
|
|
for (const lang of usedLanguages) { await ensureLanguageColumn(tableId, lang); }
|
|
|
|
// Insert sections into document table
|
|
const createdIds = [];
|
|
let orderIndex = 10;
|
|
|
|
for (const section of sections) {
|
|
const baseId = generateBaseId();
|
|
const isHeader = ['h1', 'h2', 'h3'].includes(section.level);
|
|
const contentValue = isHeader
|
|
? (section.title || section.content || '')
|
|
: (section.content || '');
|
|
const hasExplicitLangFields = Object.keys(section).some(k => /^content_[a-z]{2}$/.test(k));
|
|
|
|
const sectionData = {
|
|
order: section.order ?? orderIndex,
|
|
level: section.level || 'text',
|
|
comment: section.comment || '',
|
|
type: section.type || 'reference',
|
|
atom_ref: section.atom_ref || null,
|
|
is_collapsed: section.is_collapsed || false
|
|
};
|
|
|
|
if (hasExplicitLangFields) {
|
|
for (const key of Object.keys(section)) {
|
|
if (key.match(/^content_[a-z]{2}$/)) sectionData[key] = section[key] || '';
|
|
}
|
|
} else {
|
|
const targetLang = section.language || 'en';
|
|
sectionData[`content_${targetLang}`] = contentValue;
|
|
}
|
|
|
|
// 2026-04-27: silent floor on `order` — fractional values broke
|
|
// `ORDER BY (data->>'order')::integer` in content.js GET. Never throw —
|
|
// priority is "document doesn't break".
|
|
if (sectionData.order != null && Number.isFinite(Number(sectionData.order))) {
|
|
sectionData.order = Math.floor(Number(sectionData.order));
|
|
}
|
|
|
|
const result = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tableId, baseId, JSON.stringify(sectionData), userId]
|
|
);
|
|
createdIds.push(result.lastInsertRowid);
|
|
orderIndex += 10;
|
|
}
|
|
|
|
apiLogger.info(`[Documents v4] Imported ${createdIds.length} sections into document ${documentId}`);
|
|
|
|
// ADR-0003 C-11: if this is the first content import (source_path still
|
|
// null on the registry row), write the `_initial.md` snapshot and
|
|
// persist source_path. Best-effort — never fail the import.
|
|
let sourcePath = docData.source_path || null;
|
|
if (!sourcePath) {
|
|
try {
|
|
const { widgetIdForRegistry } = await findWidgetForRegistry(registry_table_id);
|
|
if (widgetIdForRegistry) {
|
|
const { writeInitialSnapshot } = await import('../../../services/documents/SnapshotWriter.js');
|
|
const snap = await writeInitialSnapshot({
|
|
widgetId: widgetIdForRegistry,
|
|
documentId,
|
|
docSlug: docData.slug,
|
|
title: docData.name || docData.title,
|
|
registryTableId: Number(registry_table_id),
|
|
});
|
|
if (snap?.written) sourcePath = snap.relative_path;
|
|
}
|
|
} catch (snapErr) {
|
|
apiLogger.warn({ err: snapErr.message, documentId }, '[SnapshotWriter] initial snapshot on import-v4 failed (non-fatal)');
|
|
}
|
|
}
|
|
|
|
created(res, {
|
|
document_id: documentId,
|
|
created_ids: createdIds,
|
|
count: createdIds.length,
|
|
source_path: sourcePath,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /documents/:documentId/import-v4 error:', err);
|
|
error(res, 'IMPORT_DOCUMENT_ERROR', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Look up which widget references a given registry_table_id. Documents
|
|
* widgets store this in `config.registry_table_id` (or legacy
|
|
* `documents_table_id`). Used by the snapshot hook because /import-v4
|
|
* receives only the registry id.
|
|
*/
|
|
async function findWidgetForRegistry(registryTableId) {
|
|
try {
|
|
const rows = await dbAll(
|
|
`SELECT id, config FROM widgets WHERE widget_type = 'documents' OR preset_name LIKE '%document%'`
|
|
);
|
|
const target = Number(registryTableId);
|
|
for (const w of rows) {
|
|
const cfg = typeof w.config === 'string' ? safeJsonParse(w.config, {}) : (w.config || {});
|
|
const regId = Number(cfg.registry_table_id || cfg.documents_table_id || 0);
|
|
if (regId === target) return { widgetIdForRegistry: w.id };
|
|
}
|
|
// Fallback: scan ALL widgets if preset filter missed
|
|
const allRows = await dbAll('SELECT id, config FROM widgets');
|
|
for (const w of allRows) {
|
|
const cfg = typeof w.config === 'string' ? safeJsonParse(w.config, {}) : (w.config || {});
|
|
const regId = Number(cfg.registry_table_id || cfg.documents_table_id || 0);
|
|
if (regId === target) return { widgetIdForRegistry: w.id };
|
|
}
|
|
} catch (err) {
|
|
apiLogger.warn({ err: err.message }, '[findWidgetForRegistry] lookup failed');
|
|
}
|
|
return { widgetIdForRegistry: null };
|
|
}
|
|
|
|
export default router;
|