Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
197 lines
6.2 KiB
JavaScript
197 lines
6.2 KiB
JavaScript
/**
|
|
* Table summary variable controller
|
|
* Handles: POST /tables/:tableId/columns/:columnId/summary-variable
|
|
*/
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, safeJsonParse } from '../../../database/connection.js';
|
|
import { generateBaseId } from '../../../utils/baseId.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, created, notFound, badRequest, error } from '../../../utils/response.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* POST /api/v3/tables/:tableId/columns/:columnId/summary-variable
|
|
* ADR-026: Create a Variable from column summary aggregation
|
|
*
|
|
* Creates a new Variable in the Space's Variables table that stores
|
|
* the result of an aggregation (SUM, AVG, MIN, MAX, COUNT, etc.)
|
|
* The variable can then be referenced in formulas via $variable_name
|
|
*/
|
|
router.post('/tables/:tableId/columns/:columnId/summary-variable', async (req, res) => {
|
|
try {
|
|
const { tableId, columnId } = req.params;
|
|
const { aggregation, variableName } = req.body;
|
|
const userId = req.user?.id;
|
|
|
|
apiLogger.debug({ tableId, columnId, aggregation }, 'Creating summary variable');
|
|
|
|
// Validate aggregation type
|
|
const validAggregations = [
|
|
'sum', 'avg', 'min', 'max', 'count',
|
|
'countUnique', 'countEmpty', 'countFilled',
|
|
'checked', 'unchecked', 'percentChecked',
|
|
'earliest', 'latest', 'dateRange', 'percentFilled'
|
|
];
|
|
|
|
if (!aggregation || !validAggregations.includes(aggregation)) {
|
|
return badRequest(res, `Invalid aggregation type. Valid types: ${validAggregations.join(', ')}`);
|
|
}
|
|
|
|
// Get table info
|
|
const table = await dbGet(`
|
|
SELECT id, name, display_name, project_id
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
if (!table) {
|
|
return notFound(res, 'Table');
|
|
}
|
|
|
|
// Get column info
|
|
const column = await dbGet(`
|
|
SELECT id, name, display_name, type
|
|
FROM table_columns
|
|
WHERE (id = ? OR name = ?) AND table_id = ?
|
|
`, [columnId, columnId, tableId]);
|
|
|
|
if (!column) {
|
|
return notFound(res, 'Column');
|
|
}
|
|
|
|
// Get space through project
|
|
const projectSpace = await dbGet(`
|
|
SELECT p.id as project_id, s.id as space_id
|
|
FROM projects p
|
|
JOIN spaces s ON s.id = p.space_id
|
|
WHERE p.id = ?
|
|
`, [table.project_id]);
|
|
|
|
if (!projectSpace) {
|
|
return notFound(res, 'Space');
|
|
}
|
|
|
|
// Get or create Variables table
|
|
let variablesTable = await dbGet(`
|
|
SELECT ut.id
|
|
FROM universal_tables ut
|
|
JOIN projects p ON p.id = ut.project_id
|
|
WHERE p.space_id = ? AND p.type = 'system_data' AND ut.name = 'Variables'
|
|
`, [projectSpace.space_id]);
|
|
|
|
if (!variablesTable) {
|
|
// Auto-create Variables table if it doesn't exist
|
|
const systemProject = await dbGet(`
|
|
SELECT id FROM projects
|
|
WHERE space_id = ? AND type = 'system_data'
|
|
`, [projectSpace.space_id]);
|
|
|
|
if (systemProject) {
|
|
const now = new Date().toISOString();
|
|
const createResult = await dbRun(`
|
|
INSERT INTO universal_tables (name, display_name, project_id, created_at, updated_at, is_system)
|
|
VALUES ('Variables', 'Variables', ?, ?, ?, 1)
|
|
`, [systemProject.id, now, now]);
|
|
|
|
variablesTable = { id: createResult.lastID };
|
|
apiLogger.info({ tableId: variablesTable.id }, 'Auto-created Variables table');
|
|
} else {
|
|
return badRequest(res, 'System Data project not found');
|
|
}
|
|
}
|
|
|
|
// Generate formula based on aggregation
|
|
const formulaTemplates = {
|
|
sum: (col) => `SUM({{${col}}})`,
|
|
avg: (col) => `AVG({{${col}}})`,
|
|
min: (col) => `MIN({{${col}}})`,
|
|
max: (col) => `MAX({{${col}}})`,
|
|
count: (col) => `COUNT({{${col}}})`,
|
|
countUnique: (col) => `COUNTUNIQUE({{${col}}})`,
|
|
countEmpty: (col) => `COUNTEMPTY({{${col}}})`,
|
|
countFilled: (col) => `COUNTFILLED({{${col}}})`,
|
|
checked: (col) => `COUNTIF({{${col}}}, true)`,
|
|
unchecked: (col) => `COUNTIF({{${col}}}, false)`,
|
|
percentChecked: (col) => `PERCENTIF({{${col}}}, true)`,
|
|
earliest: (col) => `MIN({{${col}}})`,
|
|
latest: (col) => `MAX({{${col}}})`,
|
|
dateRange: (col) => `DATEDIFF(MAX({{${col}}}), MIN({{${col}}}))`,
|
|
percentFilled: (col) => `PERCENTFILLED({{${col}}})`
|
|
};
|
|
|
|
const formula = formulaTemplates[aggregation](column.name);
|
|
|
|
// Generate variable name
|
|
let finalName = variableName;
|
|
if (!finalName) {
|
|
finalName = `$${column.name}_${aggregation}`;
|
|
}
|
|
|
|
// Check for uniqueness and add suffix if needed
|
|
const existingVars = await dbAll(`
|
|
SELECT data FROM table_rows WHERE table_id = ?
|
|
`, [variablesTable.id]);
|
|
|
|
const existingNames = new Set(existingVars.map(v => {
|
|
try {
|
|
const data = safeJsonParse(v.data, {});
|
|
return data.name;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}).filter(Boolean));
|
|
|
|
if (existingNames.has(finalName)) {
|
|
let suffix = 1;
|
|
while (existingNames.has(`${finalName}_${suffix}`)) {
|
|
suffix++;
|
|
}
|
|
finalName = `${finalName}_${suffix}`;
|
|
}
|
|
|
|
// Create variable row
|
|
const now = new Date().toISOString();
|
|
const variableData = {
|
|
name: finalName,
|
|
scope_type: 'table',
|
|
scope_ref: parseInt(tableId, 10),
|
|
formula: formula,
|
|
description: `${aggregation.toUpperCase()} of ${column.display_name || column.name}`,
|
|
stream_id: 1,
|
|
order_index: 0,
|
|
cached_value: null,
|
|
cached_at: null
|
|
};
|
|
|
|
const varBaseId = generateBaseId();
|
|
const result = await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`, [variablesTable.id, varBaseId, JSON.stringify(variableData), userId, now, now]);
|
|
|
|
apiLogger.info({
|
|
variableId: result.lastInsertRowid || result.lastID,
|
|
name: finalName,
|
|
tableId,
|
|
columnId
|
|
}, 'Summary variable created');
|
|
|
|
created(res, {
|
|
variable: {
|
|
id: result.lastInsertRowid || result.lastID,
|
|
name: finalName,
|
|
formula: formula,
|
|
scope: 'table',
|
|
scopeRef: parseInt(tableId, 10),
|
|
value: null
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST summary-variable error');
|
|
error(res, 'CREATE_VARIABLE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|