Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
383 lines
12 KiB
JavaScript
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;
|