godcrm/backend/routes/v3/documents/content.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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;