godcrm/backend/routes/v3/documents/structure.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

174 lines
7.6 KiB
JavaScript

// Documents: Structure management — update, rebuild, setup-columns
import express from 'express';
import {
dbAll, dbGet, dbRun, safeJsonParse,
apiLogger,
success, error, badRequest, notFound,
requireEditorAccess,
} from './_helpers.js';
const router = express.Router();
/** PUT /api/v3/documents/:documentId/structure */
router.put('/documents/:documentId/structure', async (req, res) => {
try {
const { documentId } = req.params;
const { documents_table_id, structure } = req.body;
if (!documents_table_id) return badRequest(res, 'documents_table_id is required');
if (!structure || structure.version !== 2) return badRequest(res, 'Structure must have version 2');
const structDocsTable = await dbGet('SELECT project_id FROM universal_tables WHERE id = ?', [documents_table_id]);
if (structDocsTable?.project_id) {
if (!(await requireEditorAccess(req, res, structDocsTable.project_id))) return;
}
const docRow = await dbGet('SELECT * FROM table_rows WHERE id = ? AND table_id = ?', [documentId, documents_table_id]);
if (!docRow) return notFound(res, 'Document not found');
const docData = safeJsonParse(docRow.data, {});
docData.structure = JSON.stringify(structure);
await dbRun('UPDATE table_rows SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [JSON.stringify(docData), documentId]);
apiLogger.info(`[Documents] Updated structure for document ${documentId}`);
success(res, { structure });
} catch (err) {
apiLogger.error({ err }, 'PUT /documents/:id/structure error:', err);
error(res, 'UPDATE_STRUCTURE_ERROR', err.message, 500);
}
});
/** POST /api/v3/documents/:documentId/rebuild-structure */
router.post('/documents/:documentId/rebuild-structure', async (req, res) => {
try {
const { documentId } = req.params;
const { documents_table_id, sections_table_id } = req.body;
if (!documents_table_id || !sections_table_id) return badRequest(res, 'documents_table_id and sections_table_id are required');
const rebuildDocsTable = await dbGet('SELECT project_id FROM universal_tables WHERE id = ?', [documents_table_id]);
if (rebuildDocsTable?.project_id) {
if (!(await requireEditorAccess(req, res, rebuildDocsTable.project_id))) return;
}
const docRow = await dbGet('SELECT * FROM table_rows WHERE id = ? AND table_id = ?', [documentId, documents_table_id]);
if (!docRow) return notFound(res, 'Document not found');
const docData = safeJsonParse(docRow.data, {});
// Parse sections array
let sectionIds = [];
if (typeof docData.sections === 'string') { try { sectionIds = JSON.parse(docData.sections); } catch { sectionIds = []; } }
else if (Array.isArray(docData.sections)) { sectionIds = docData.sections; }
// Fetch all sections
const sections = [];
for (const sectionId of sectionIds) {
const numId = typeof sectionId === 'string' ? parseInt(sectionId, 10) : sectionId;
if (isNaN(numId)) continue;
const sectionRow = await dbGet('SELECT * FROM table_rows WHERE id = ? AND table_id = ?', [numId, sections_table_id]);
if (sectionRow) sections.push({ id: sectionRow.id, ...safeJsonParse(sectionRow.data, {}) });
}
// Build hierarchy
const h2Sections = sections
.filter(s => (s.h2 && !s.h3) || (!s.h2 && !s.h3 && !s.parent))
.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
const sectionChildren = new Map();
for (const section of sections) {
if (section.parent) {
const parentId = typeof section.parent === 'number' ? section.parent :
Array.isArray(section.parent) ? section.parent[0] : parseInt(section.parent, 10);
if (!sectionChildren.has(parentId)) sectionChildren.set(parentId, []);
sectionChildren.get(parentId).push(section);
}
}
for (const [, children] of sectionChildren) {
children.sort((a, b) => (a.local_order || a.order_index || 0) - (b.local_order || b.order_index || 0));
}
const structure = {
version: 2,
title: docData.name || '',
description: docData.description || '',
sections: h2Sections.map((h2, index) => ({
id: h2.id, order: index + 1,
children: (sectionChildren.get(h2.id) || []).map(c => c.id),
footer: null, collapsed: false
})),
footer: null, links: []
};
// Preserve existing footer/links from old structure
if (docData.structure) {
try {
const oldStructure = typeof docData.structure === 'string' ? JSON.parse(docData.structure) : docData.structure;
if (oldStructure.footer) structure.footer = oldStructure.footer;
if (oldStructure.links) structure.links = oldStructure.links;
if (oldStructure.sections) {
for (const oldSection of oldStructure.sections) {
const newSection = structure.sections.find(s => s.id === oldSection.id);
if (newSection && oldSection.footer) newSection.footer = oldSection.footer;
if (newSection && oldSection.collapsed) newSection.collapsed = oldSection.collapsed;
}
}
} catch (e) { /* Ignore parse errors */ }
}
docData.structure = JSON.stringify(structure);
await dbRun('UPDATE table_rows SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [JSON.stringify(docData), documentId]);
apiLogger.info(`[Documents] Rebuilt structure for document ${documentId}: ${h2Sections.length} H2 sections`);
success(res, { structure });
} catch (err) {
apiLogger.error({ err }, 'POST /documents/:id/rebuild-structure error:', err);
error(res, 'REBUILD_STRUCTURE_ERROR', err.message, 500);
}
});
/** POST /api/v3/documents/setup-columns */
router.post('/documents/setup-columns', async (req, res) => {
try {
const { sections_table_id } = req.body;
if (!sections_table_id) return badRequest(res, 'sections_table_id is required');
const existingColumns = await dbAll('SELECT column_name FROM table_columns WHERE table_id = ?', [sections_table_id]);
const columnNames = existingColumns.map(c => c.column_name);
const columnsToAdd = [];
const maxOrder = await dbGet('SELECT MAX(order_index) as max_order FROM table_columns WHERE table_id = ?', [sections_table_id]);
let nextOrder = (maxOrder?.max_order || 0) + 1;
if (!columnNames.includes('h2_order')) {
columnsToAdd.push({
column_name: 'h2_order', display_name: 'H2 Order', type: 'number', order_index: nextOrder++,
config: JSON.stringify({ description: 'Order of parent H2 section (for sorting)', default: 0 })
});
}
if (!columnNames.includes('h3_order')) {
columnsToAdd.push({
column_name: 'h3_order', display_name: 'H3 Order', type: 'number', order_index: nextOrder++,
config: JSON.stringify({ description: 'Order within H2 (0 for H2 itself)', default: 0 })
});
}
for (const col of columnsToAdd) {
await dbRun(
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config) VALUES (?, ?, ?, ?, ?, 1, ?)`,
[sections_table_id, col.column_name, col.display_name, col.type, col.order_index, col.config]
);
}
apiLogger.info(`[Documents] Added ${columnsToAdd.length} sorting columns to table ${sections_table_id}`);
success(res, {
added_columns: columnsToAdd.map(c => c.column_name),
message: columnsToAdd.length > 0
? `Added columns: ${columnsToAdd.map(c => c.column_name).join(', ')}`
: 'All columns already exist'
});
} catch (err) {
apiLogger.error({ err }, 'POST /documents/setup-columns error:', err);
error(res, 'SETUP_COLUMNS_ERROR', err.message, 500);
}
});
export default router;