Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
348 lines
11 KiB
JavaScript
348 lines
11 KiB
JavaScript
/**
|
|
* Table creation and single-table read controller
|
|
* Handles: GET /users, GET /tables/:tableId, POST /tables/create-calendar, POST /tables
|
|
*/
|
|
import express from 'express';
|
|
import { dbAll, dbGet, dbRun, toBool, sqlNow } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, notFound, badRequest, forbidden, unauthorized, error } from '../../../utils/response.js';
|
|
import { checkTableAccess } from './helpers.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* GET /api/v3/users
|
|
* Get all system users
|
|
*/
|
|
router.get('/users', async (req, res) => {
|
|
try {
|
|
const users = await dbAll(`
|
|
SELECT u.id, u.name, u.email,
|
|
CASE WHEN length(u.avatar) > 2048 THEN NULL ELSE u.avatar END as avatar,
|
|
u.user_type,
|
|
u.managed_by_agent_table_id, u.managed_by_agent_row_id,
|
|
(SELECT r.data->>'icon' FROM table_rows r
|
|
WHERE r.id = u.managed_by_agent_row_id
|
|
AND r.table_id = u.managed_by_agent_table_id) as agent_icon,
|
|
(SELECT r.data->>'color' FROM table_rows r
|
|
WHERE r.id = u.managed_by_agent_row_id
|
|
AND r.table_id = u.managed_by_agent_table_id) as agent_color
|
|
FROM users u
|
|
ORDER BY u.name
|
|
`);
|
|
|
|
success(res, users || []);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /users error');
|
|
error(res, 'USERS_FETCH_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/tables/:tableId
|
|
* Get table by ID
|
|
*/
|
|
router.get('/tables/:tableId', async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
|
|
// Check access if API key has project restriction
|
|
if (req.user?.projectId) {
|
|
const access = await checkTableAccess(tableId, req.user);
|
|
if (!access.allowed) {
|
|
return forbidden(res, access.error);
|
|
}
|
|
}
|
|
|
|
const table = await dbGet(`
|
|
SELECT
|
|
id,
|
|
project_id,
|
|
name,
|
|
icon,
|
|
description,
|
|
is_system,
|
|
sync_target,
|
|
data_source_id,
|
|
source_table_name,
|
|
source_id_column,
|
|
sync_enabled,
|
|
sync_interval_minutes,
|
|
last_sync_at,
|
|
parent_table_id,
|
|
config,
|
|
created_at,
|
|
updated_at
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
if (!table) {
|
|
return notFound(res, 'Table');
|
|
}
|
|
|
|
success(res, table);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /tables/:tableId error');
|
|
error(res, 'TABLE_FETCH_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/tables/create-calendar
|
|
* Create a calendar table with weekend/holiday data
|
|
* IMPORTANT: This route must be defined BEFORE /tables/:tableId routes
|
|
*/
|
|
router.post('/tables/create-calendar', async (req, res) => {
|
|
try {
|
|
const { projectId, tableName = 'Calendar' } = req.body;
|
|
const userId = req.user?.id || req.user?.userId;
|
|
|
|
apiLogger.debug({ projectId, tableName, userId }, 'CREATE CALENDAR request received');
|
|
|
|
if (!projectId) {
|
|
return badRequest(res, 'projectId is required');
|
|
}
|
|
|
|
if (!userId) {
|
|
return unauthorized(res, 'User authentication required');
|
|
}
|
|
|
|
// Check if project exists
|
|
const project = await dbGet('SELECT id, space_id FROM projects WHERE id = ?', [projectId]);
|
|
if (!project) {
|
|
return notFound(res, 'Project');
|
|
}
|
|
|
|
apiLogger.debug({ tableName, projectId }, 'Creating calendar table');
|
|
|
|
// Import the service dynamically
|
|
const { createCalendarTable } = await import('../../../services/CalendarTableService.js');
|
|
|
|
// Create the calendar table
|
|
const tableId = await createCalendarTable(projectId, userId, tableName);
|
|
|
|
// Get the created table info
|
|
const table = await dbGet(`
|
|
SELECT t.*, p.name as project_name, p.icon as project_icon
|
|
FROM universal_tables t
|
|
LEFT JOIN projects p ON t.project_id = p.id
|
|
WHERE t.id = ?
|
|
`, [tableId]);
|
|
|
|
apiLogger.info({ tableId }, 'CREATE CALENDAR success');
|
|
|
|
success(res, {
|
|
tableId,
|
|
table: {
|
|
id: table.id,
|
|
name: table.name,
|
|
icon: table.icon,
|
|
description: table.description,
|
|
projectId: table.project_id,
|
|
projectName: table.project_name
|
|
}
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /tables/create-calendar error');
|
|
error(res, 'CREATE_CALENDAR_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/v3/tables
|
|
* Create a new table
|
|
*/
|
|
router.post('/tables', async (req, res) => {
|
|
try {
|
|
const { name, description, icon, projectId, project_id, columns, data_source_id, external_table_name } = req.body;
|
|
// Support both projectId and project_id for compatibility
|
|
const effectiveProjectId = projectId || project_id;
|
|
// Stable module marker (ADR-164 style). Accept snake_case and the camelCase
|
|
// `tableType` the widget presets declare; persist so a module's tables are
|
|
// discoverable by type per-project (e.g. SC-SIM sim_runs) instead of by a
|
|
// hard-coded global id. Absent → null, preserving legacy behaviour.
|
|
const tableType = req.body.table_type || req.body.tableType || null;
|
|
|
|
if (!name) {
|
|
return badRequest(res, 'Table name is required');
|
|
}
|
|
|
|
if (!effectiveProjectId) {
|
|
return badRequest(res, 'Project ID is required');
|
|
}
|
|
|
|
// Create the table with optional data source connection
|
|
const result = await dbRun(`
|
|
INSERT INTO universal_tables (
|
|
project_id,
|
|
name,
|
|
description,
|
|
icon,
|
|
is_system,
|
|
table_type,
|
|
data_source_id,
|
|
source_table_name,
|
|
sync_enabled,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [
|
|
effectiveProjectId,
|
|
name,
|
|
description || null,
|
|
icon || '📊',
|
|
toBool(false),
|
|
tableType,
|
|
data_source_id || null,
|
|
external_table_name || null,
|
|
toBool(!!data_source_id)
|
|
]);
|
|
|
|
const tableId = result.lastID || result.lastInsertRowid;
|
|
|
|
if (!tableId) {
|
|
apiLogger.error({ result }, 'Failed to get table ID from insert result');
|
|
throw new Error('Failed to create table: no ID returned');
|
|
}
|
|
|
|
apiLogger.info({ tableId }, 'Table created');
|
|
|
|
// Create columns
|
|
const createdColumns = [];
|
|
|
|
// If external table, auto-import columns from data source
|
|
if (data_source_id && external_table_name) {
|
|
try {
|
|
apiLogger.debug({ external_table_name }, 'Importing columns from external table');
|
|
const dataSourceService = await import('../../../services/DataSourceService.js');
|
|
apiLogger.debug('DataSourceService loaded');
|
|
const externalColumns = await dataSourceService.default.listTableColumns(data_source_id, external_table_name);
|
|
apiLogger.debug({ count: externalColumns.length, columns: externalColumns }, 'External columns fetched');
|
|
|
|
for (let i = 0; i < externalColumns.length; i++) {
|
|
const col = externalColumns[i];
|
|
// Map external type to our internal type
|
|
let internalType = 'text';
|
|
if (col.type.includes('int') || col.type.includes('decimal') || col.type.includes('float')) {
|
|
internalType = 'number';
|
|
} else if (col.type.includes('date') || col.type.includes('time')) {
|
|
internalType = 'date';
|
|
} else if (col.type.includes('bool')) {
|
|
internalType = 'checkbox';
|
|
}
|
|
|
|
apiLogger.debug({ name: col.name, type: internalType }, 'Inserting column');
|
|
|
|
const columnResult = await dbRun(`
|
|
INSERT INTO table_columns (
|
|
table_id,
|
|
column_name,
|
|
display_name,
|
|
type,
|
|
is_required,
|
|
order_index,
|
|
is_visible,
|
|
config,
|
|
is_from_source,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [
|
|
tableId,
|
|
col.name,
|
|
col.name,
|
|
internalType,
|
|
toBool(false),
|
|
i,
|
|
toBool(true),
|
|
JSON.stringify({}),
|
|
toBool(true)
|
|
]);
|
|
|
|
apiLogger.debug({ columnId: columnResult.lastID || columnResult.lastInsertRowid }, 'Column inserted');
|
|
|
|
createdColumns.push({
|
|
id: columnResult.lastID || columnResult.lastInsertRowid,
|
|
table_id: tableId,
|
|
column_name: col.name,
|
|
display_name: col.name,
|
|
type: internalType
|
|
});
|
|
}
|
|
apiLogger.debug({ count: createdColumns.length }, "Imported columns from external table");
|
|
} catch (error) {
|
|
apiLogger.error({ err: error }, 'Failed to import external columns');
|
|
apiLogger.error({ stack: error.stack }, 'Error stack');
|
|
// Continue without columns - user can map them later
|
|
}
|
|
} else if (columns && Array.isArray(columns) && columns.length > 0) {
|
|
// Local table with provided columns
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const col = columns[i];
|
|
const columnResult = await dbRun(`
|
|
INSERT INTO table_columns (
|
|
table_id,
|
|
column_name,
|
|
display_name,
|
|
type,
|
|
is_required,
|
|
order_index,
|
|
is_visible,
|
|
config,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
|
`, [
|
|
tableId,
|
|
col.name,
|
|
col.displayName || col.name,
|
|
col.type,
|
|
toBool(col.isRequired),
|
|
col.orderIndex ?? i,
|
|
toBool(col.isVisible !== false),
|
|
JSON.stringify(col.config || {})
|
|
]);
|
|
|
|
createdColumns.push({
|
|
id: columnResult.lastID || columnResult.lastInsertRowid,
|
|
table_id: tableId,
|
|
column_name: col.name,
|
|
display_name: col.displayName || col.name,
|
|
type: col.type,
|
|
is_required: col.isRequired ? 1 : 0,
|
|
order_index: col.orderIndex ?? i,
|
|
is_visible: col.isVisible !== false ? 1 : 0,
|
|
config: col.config || {}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fetch the created table
|
|
const newTable = await dbGet(`
|
|
SELECT
|
|
id,
|
|
project_id,
|
|
name,
|
|
icon,
|
|
description,
|
|
is_system,
|
|
sync_target,
|
|
data_source_id,
|
|
created_at,
|
|
updated_at
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
success(res, {
|
|
table: newTable,
|
|
columns: createdColumns
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /tables error');
|
|
error(res, 'TABLE_CREATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|