godcrm/backend/routes/v3/labs/nodes.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

383 lines
12 KiB
JavaScript

/**
* Labs Nodes Routes (v4 table-based)
* CRUD for nodes in labs_nodes table
* Legacy node endpoints are in legacy.js
*/
import { Router } from 'express';
import { dbGet, dbRun, dbAll } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, created, notFound, badRequest } from '../../../utils/response.js';
import { LabsService } from '../../../services/labs/index.js';
import { getNodeType } from '../../../services/labs/node-types/index.js';
import { generateId, toFlowNode } from './_helpers.js';
const router = Router();
/**
* GET /api/v3/labs/:labTableId/nodes
* Get all nodes from lab's table (uses labs_nodes table)
*/
router.get('/:labTableId/nodes', async (req, res) => {
try {
const { labTableId } = req.params;
apiLogger.info({ labTableId }, 'Getting lab nodes for table');
// Get nodes from labs_nodes table
const nodes = await dbAll(`
SELECT * FROM labs_nodes
WHERE lab_id = $1
ORDER BY created_at
`, [labTableId]);
// Get edges for connections
const edges = await dbAll('SELECT * FROM labs_edges WHERE lab_id = $1', [labTableId]);
// Transform nodes to MindWorkflow FlowNode format
const parsedNodes = nodes.map(node => toFlowNode(node, edges));
apiLogger.info({ labTableId, count: parsedNodes.length }, 'Lab nodes fetched');
success(res, parsedNodes);
} catch (err) {
apiLogger.error({ err, labTableId: req.params.labTableId }, 'Failed to get lab nodes');
badRequest(res, 'Failed to get lab nodes');
}
});
/**
* POST /api/v3/labs/:labTableId/nodes
* Create node in lab's table
*/
router.post('/:labTableId/nodes', async (req, res) => {
try {
const { labTableId } = req.params;
const nodeData = req.body;
const userId = req.user?.id;
// Support both MindWorkflow format (type) and GOD CRM format (type_key)
const typeKey = nodeData.type_key || nodeData.type || 'text';
// Validate node type exists
const nodeType = getNodeType(typeKey);
if (!nodeType) {
return badRequest(res, `Invalid node type: ${typeKey}`);
}
// Create node with defaults from node type
const completeNodeData = LabsService.createNodeWithDefaults(typeKey, nodeData);
// Validate the complete node data
const validation = LabsService.validateNode(completeNodeData);
if (!validation.valid) {
return badRequest(res, `Validation failed: ${validation.errors.join(', ')}`);
}
// Insert into labs_nodes table (not table_rows)
const nodeId = completeNodeData.node_id || generateId('node');
// Get position from various sources
const posX = completeNodeData.position?.x ?? completeNodeData.position_x ?? 0;
const posY = completeNodeData.position?.y ?? completeNodeData.position_y ?? 0;
// Get edges as array (not stringified)
const edgesArray = Array.isArray(completeNodeData.edges) ? completeNodeData.edges : [];
// Get config as object (not stringified)
const configObj = typeof completeNodeData.config === 'object' && completeNodeData.config !== null
? completeNodeData.config
: {};
// Build meta object with position, dimensions, edges, etc.
const meta = {
position: { x: posX, y: posY },
width: completeNodeData.width || nodeType.defaultWidth || 300,
height: completeNodeData.height || nodeType.defaultHeight || 200,
edges: edgesArray,
config: configObj,
...(completeNodeData.meta || {})
};
// Build ui_config object
const uiConfig = completeNodeData.ui_config || {};
// Build ai_config object
const aiConfig = completeNodeData.ai_config || {};
const result = await dbRun(`
INSERT INTO labs_nodes (
lab_id, node_id, type, title, content,
meta, ai_config, ai_agent_id, ai_provider_id, ui_config,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
NOW(), NOW()
)
`, [
labTableId,
nodeId,
typeKey,
completeNodeData.title || nodeType.label || 'Untitled',
completeNodeData.content || '',
JSON.stringify(meta),
JSON.stringify(aiConfig),
completeNodeData.ai_agent_id || null,
completeNodeData.ai_provider_id || null,
JSON.stringify(uiConfig)
]);
// Fetch the created node
const node = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1', [nodeId]);
if (!node) {
return badRequest(res, 'Failed to fetch created node');
}
// Transform to MindWorkflow FlowNode format
const flowNode = toFlowNode(node, []);
apiLogger.info({ nodeType: typeKey, nodeId: flowNode.node_id }, 'Created lab node');
created(res, flowNode);
} catch (err) {
apiLogger.error({ err }, 'Failed to create lab node');
badRequest(res, 'Failed to create lab node');
}
});
/**
* Helper: build merged meta from existing node and updates
*/
function buildMergedMeta(existingMeta, updates) {
const newMeta = { ...existingMeta };
// Handle position updates
if (updates.position) {
newMeta.position = {
x: updates.position.x ?? existingMeta.position?.x ?? 0,
y: updates.position.y ?? existingMeta.position?.y ?? 0
};
}
if (updates.position_x !== undefined || updates.position_y !== undefined) {
newMeta.position = {
x: updates.position_x ?? existingMeta.position?.x ?? 0,
y: updates.position_y ?? existingMeta.position?.y ?? 0
};
}
// Handle MindWorkflow ui.bbox format
if (updates.ui?.bbox) {
newMeta.position = {
x: updates.ui.bbox.x1 ?? existingMeta.position?.x ?? 0,
y: updates.ui.bbox.y1 ?? existingMeta.position?.y ?? 0
};
newMeta.width = (updates.ui.bbox.x2 - updates.ui.bbox.x1) || existingMeta.width || 300;
newMeta.height = (updates.ui.bbox.y2 - updates.ui.bbox.y1) || existingMeta.height || 200;
}
// Handle dimensions
if (updates.width !== undefined) newMeta.width = updates.width;
if (updates.height !== undefined) newMeta.height = updates.height;
// Handle edges array
if (updates.edges !== undefined) {
newMeta.edges = Array.isArray(updates.edges) ? updates.edges : [];
}
// Handle config object
if (updates.config !== undefined) {
newMeta.config = typeof updates.config === 'object' ? updates.config : {};
}
// Handle meta/config updates (MindWorkflow sends meta as config)
if (updates.meta !== undefined) {
newMeta.config = typeof updates.meta === 'object' ? updates.meta : {};
}
return newMeta;
}
/**
* Helper: parse existing meta from node
*/
function parseExistingMeta(node) {
if (!node.meta) return {};
if (typeof node.meta === 'string') {
try { return JSON.parse(node.meta); } catch (e) { return {}; }
}
return node.meta;
}
/**
* PUT /api/v3/labs/:labTableId/nodes/:nodeId
* Update node in labs_nodes table
*/
router.put('/:labTableId/nodes/:nodeId', async (req, res) => {
try {
const { labTableId, nodeId } = req.params;
const updates = req.body;
const existingNode = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1 AND lab_id = $2', [nodeId, labTableId]);
if (!existingNode) {
return notFound(res, 'Node not found');
}
const existingMeta = parseExistingMeta(existingNode);
const newMeta = buildMergedMeta(existingMeta, updates);
// Build dynamic UPDATE query
const directFields = ['type', 'title', 'content', 'ai_agent_id', 'ai_provider_id', 'ai_visible'];
const setClauses = [];
const values = [];
let paramIndex = 1;
for (const field of directFields) {
if (updates[field] !== undefined) {
setClauses.push(`${field} = $${paramIndex}`);
values.push(updates[field]);
paramIndex++;
}
}
// Always update meta with merged values
setClauses.push(`meta = $${paramIndex}`);
values.push(JSON.stringify(newMeta));
paramIndex++;
if (updates.ai_config !== undefined) {
setClauses.push(`ai_config = $${paramIndex}`);
values.push(JSON.stringify(updates.ai_config));
paramIndex++;
}
if (updates.ui_config !== undefined) {
setClauses.push(`ui_config = $${paramIndex}`);
values.push(JSON.stringify(updates.ui_config));
paramIndex++;
}
if (updates.ai_routing_config !== undefined) {
setClauses.push(`ai_routing_config = $${paramIndex}`);
values.push(JSON.stringify(updates.ai_routing_config));
paramIndex++;
}
setClauses.push(`updated_at = NOW()`);
values.push(nodeId);
await dbRun(`
UPDATE labs_nodes
SET ${setClauses.join(', ')}
WHERE node_id = $${paramIndex}
`, values);
const node = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1', [nodeId]);
if (!node) {
return notFound(res, 'Node not found after update');
}
const edges = await dbAll('SELECT * FROM labs_edges WHERE lab_id = $1', [labTableId]);
const flowNode = toFlowNode(node, edges);
success(res, flowNode);
} catch (err) {
apiLogger.error({ err }, 'Failed to update lab node');
badRequest(res, 'Failed to update lab node');
}
});
// PATCH is alias for PUT (MindWorkflow uses PATCH for node updates)
router.patch('/:labTableId/nodes/:nodeId', async (req, res) => {
try {
const { labTableId, nodeId } = req.params;
const updates = req.body;
const existingNode = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1 AND lab_id = $2', [nodeId, labTableId]);
if (!existingNode) {
return notFound(res, 'Node not found');
}
const existingMeta = parseExistingMeta(existingNode);
const newMeta = buildMergedMeta(existingMeta, updates);
// Build dynamic UPDATE query
const directFields = ['type', 'title', 'content', 'ai_agent_id', 'ai_provider_id', 'ai_visible'];
const setClauses = [];
const values = [];
let paramIndex = 1;
for (const field of directFields) {
if (updates[field] !== undefined) {
setClauses.push(`${field} = $${paramIndex}`);
values.push(updates[field]);
paramIndex++;
}
}
setClauses.push(`meta = $${paramIndex}`);
values.push(JSON.stringify(newMeta));
paramIndex++;
// Handle ai_config (MindWorkflow sends as 'ai')
if (updates.ai !== undefined || updates.ai_config !== undefined) {
setClauses.push(`ai_config = $${paramIndex}`);
values.push(JSON.stringify(updates.ai || updates.ai_config));
paramIndex++;
}
// Handle ui_config
if (updates.ui?.color !== undefined) {
const existingUiConfig = existingNode.ui_config ?
(typeof existingNode.ui_config === 'string' ? JSON.parse(existingNode.ui_config) : existingNode.ui_config) : {};
setClauses.push(`ui_config = $${paramIndex}`);
values.push(JSON.stringify({ ...existingUiConfig, color: updates.ui.color }));
paramIndex++;
}
setClauses.push(`updated_at = NOW()`);
values.push(nodeId);
await dbRun(`
UPDATE labs_nodes
SET ${setClauses.join(', ')}
WHERE node_id = $${paramIndex}
`, values);
const node = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1', [nodeId]);
if (!node) {
return notFound(res, 'Node not found after update');
}
const edges = await dbAll('SELECT * FROM labs_edges WHERE lab_id = $1', [labTableId]);
const flowNode = toFlowNode(node, edges);
success(res, flowNode);
} catch (err) {
apiLogger.error({ err }, 'Failed to update lab node (PATCH)');
badRequest(res, 'Failed to update lab node');
}
});
/**
* DELETE /api/v3/labs/:labTableId/nodes/:nodeId
* Delete node from labs_nodes table
*/
router.delete('/:labTableId/nodes/:nodeId', async (req, res) => {
try {
const { labTableId, nodeId } = req.params;
const node = await dbGet('SELECT * FROM labs_nodes WHERE node_id = $1 AND lab_id = $2', [nodeId, labTableId]);
if (!node) {
return notFound(res, 'Node not found');
}
await dbRun('DELETE FROM labs_nodes WHERE node_id = $1', [nodeId]);
await dbRun('DELETE FROM labs_edges WHERE source_node_id = $1 OR target_node_id = $1', [nodeId]);
success(res, { deleted: true });
} catch (err) {
apiLogger.error({ err }, 'Failed to delete lab node');
badRequest(res, 'Failed to delete lab node');
}
});
export default router;