godcrm/backend/routes/v3/tables/tableManageController.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

431 lines
13 KiB
JavaScript

/**
* Table management controller
* Handles: GET /tables, GET /projects/:projectId/tables,
* POST /tables/:tableId/connect, PATCH /tables/:tableId,
* DELETE /tables/:tableId
*/
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, error } from '../../../utils/response.js';
import {
enableBookingConstraint,
disableBookingConstraint,
getBookingConstraint,
} from '../../../lib/booking-constraint.js';
const router = express.Router();
/**
* GET /api/v3/tables
* Get all tables (with optional filters: project_id, space_id)
*/
router.get('/tables', async (req, res) => {
try {
let { project_id, space_id } = req.query;
// If API key has project restriction, enforce it
if (req.user?.projectId) {
if (project_id && Number(project_id) !== req.user.projectId) {
return forbidden(res, 'Your API key is restricted to a different project');
}
project_id = req.user.projectId;
}
const conditions = [];
const params = [];
if (project_id) {
conditions.push('ut.project_id = ?');
params.push(project_id);
}
if (space_id) {
conditions.push('ut.project_id IN (SELECT id FROM projects WHERE space_id = ?)');
params.push(space_id);
}
const whereClause = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
const query = `
SELECT
ut.id,
ut.project_id,
ut.name,
ut.display_name,
ut.icon,
ut.description,
ut.is_system,
ut.table_type,
ut.sync_target,
ut.data_source_id,
ut.source_table_name,
ut.source_id_column,
ut.sync_enabled,
ut.parent_table_id,
ut.config,
ut.created_at,
ut.updated_at
FROM universal_tables ut
${whereClause}
ORDER BY ut.created_at ASC
`;
const tables = await dbAll(query, params);
success(res, tables);
} catch (err) {
apiLogger.error({ err }, 'GET /tables error');
error(res, 'TABLES_FETCH_FAILED', err.message, 500);
}
});
/**
* GET /api/v3/projects/:projectId/tables
* Get all tables for a project
*/
router.get('/projects/:projectId/tables', async (req, res) => {
try {
const { projectId } = req.params;
const tables = await dbAll(`
SELECT
t.id,
t.project_id,
t.name,
t.display_name,
t.icon,
t.description,
t.is_system,
t.table_type,
t.sync_target,
t.data_source_id,
t.source_table_name,
t.source_id_column,
t.sync_enabled,
t.parent_table_id,
t.show_in_nav,
t.config,
t.created_at,
t.updated_at,
ds.name as data_source_name
FROM universal_tables t
LEFT JOIN data_sources ds ON t.data_source_id = ds.id
WHERE t.project_id = ?
ORDER BY t.created_at ASC
`, [projectId]);
success(res, tables);
} catch (err) {
apiLogger.error({ err }, 'Error fetching tables');
error(res, 'FETCH_ERROR', err.message, 500);
}
});
/**
* POST /api/v3/tables/:tableId/connect
* Connect table to external data source
*/
router.post('/tables/:tableId/connect', async (req, res) => {
try {
const { tableId } = req.params;
const { data_source_id, source_table_name, source_id_column } = req.body;
if (!data_source_id || !source_table_name) {
return badRequest(res, 'data_source_id and source_table_name are required');
}
await dbRun(`
UPDATE universal_tables
SET
data_source_id = ?,
source_table_name = ?,
source_id_column = ?,
sync_enabled = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [data_source_id, source_table_name, source_id_column || 'id', tableId]);
// Create columns from external source
const DataSourceService = (await import('../../../services/DataSourceService.js')).default;
const dataSourceService = new DataSourceService();
try {
const externalColumns = await dataSourceService.listTableColumns(data_source_id, source_table_name);
apiLogger.debug({ count: externalColumns.length }, "Found columns in external source");
// Check if columns already exist
const existingColumns = await dbAll('SELECT COUNT(*) as count FROM table_columns WHERE table_id = ?', [tableId]);
if (existingColumns[0].count === 0) {
// Create columns
for (let i = 0; i < externalColumns.length; i++) {
const col = externalColumns[i];
// Map external type to 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';
}
await dbRun(`
INSERT INTO table_columns (
table_id, column_name, display_name, type,
is_required, order_index, is_visible,
is_from_source, config,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
`, [
tableId,
col.name,
col.name.toUpperCase(),
internalType,
toBool(false),
i,
toBool(true),
toBool(true),
JSON.stringify({})
]);
}
apiLogger.debug({ count: externalColumns.length, tableId }, "Created columns for table");
}
} catch (colError) {
apiLogger.error({ err: colError }, 'Failed to create columns');
// Continue anyway - columns can be created later
}
const updatedTable = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [tableId]);
success(res, updatedTable);
} catch (err) {
apiLogger.error({ err }, 'POST /tables/:tableId/connect error');
error(res, 'CONNECT_FAILED', err.message, 500);
}
});
/**
* PATCH /api/v3/tables/:tableId
* Update table display settings
*/
router.patch('/tables/:tableId', async (req, res) => {
try {
const { tableId } = req.params;
const { name, displayName, icon, color, access_control, show_in_nav, privacy, min_row_height, max_row_height, fixed_row_height, project_id, is_public } = req.body;
apiLogger.debug({ tableId, name, displayName, icon, color, show_in_nav, privacy, min_row_height, max_row_height, fixed_row_height, project_id, hasAccessControl: !!access_control }, "PATCH table display settings");
// Check if table exists
const table = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [tableId]);
if (!table) {
return notFound(res, 'Table');
}
// Build update query dynamically
const updates = [];
const params = [];
if (name !== undefined) {
updates.push('name = ?');
params.push(name);
}
if (displayName !== undefined) {
updates.push('display_name = ?');
params.push(displayName);
}
if (icon !== undefined) {
updates.push('icon = ?');
params.push(icon);
}
if (color !== undefined) {
updates.push('color = ?');
params.push(color);
}
if (access_control !== undefined) {
updates.push('access_control = ?');
params.push(access_control ? JSON.stringify(access_control) : null);
}
if (show_in_nav !== undefined) {
updates.push('show_in_nav = ?');
params.push(show_in_nav ? 1 : 0);
}
if (project_id !== undefined) {
updates.push('project_id = ?');
params.push(project_id);
}
if (is_public !== undefined) {
updates.push('is_public = ?');
params.push(Boolean(is_public));
}
// Handle config JSON fields (privacy, row height settings)
const hasConfigUpdate = privacy !== undefined ||
min_row_height !== undefined ||
max_row_height !== undefined ||
fixed_row_height !== undefined;
if (hasConfigUpdate) {
let currentConfig = {};
if (table.config) {
try {
currentConfig = typeof table.config === 'string' ? JSON.parse(table.config) : table.config;
} catch (e) {
currentConfig = {};
}
}
if (privacy !== undefined) {
currentConfig.privacy = privacy;
}
if (min_row_height !== undefined) {
currentConfig.min_row_height = min_row_height;
}
if (max_row_height !== undefined) {
currentConfig.max_row_height = max_row_height;
}
if (fixed_row_height !== undefined) {
currentConfig.fixed_row_height = fixed_row_height;
}
updates.push('config = ?');
params.push(JSON.stringify(currentConfig));
}
if (updates.length === 0) {
return badRequest(res, 'No fields to update');
}
updates.push(`updated_at = ${sqlNow()}`);
params.push(tableId);
const query = `UPDATE universal_tables SET ${updates.join(', ')} WHERE id = ?`;
apiLogger.debug({ query, params }, 'PATCH table query');
await dbRun(query, params);
const updatedTable = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [tableId]);
success(res, updatedTable);
} catch (err) {
apiLogger.error({ err }, 'PATCH /tables/:tableId error');
error(res, 'UPDATE_FAILED', err.message, 500);
}
});
/**
* DELETE /api/v3/tables/:tableId
* Delete a table and all its columns and rows (CASCADE)
*/
router.delete('/tables/:tableId', async (req, res) => {
try {
const { tableId } = req.params;
const userId = req.user?.id;
// Check if table exists and user has access
const table = await dbGet(`
SELECT t.*, p.owner_id
FROM universal_tables t
INNER JOIN projects p ON t.project_id = p.id
WHERE t.id = ?
`, [tableId]);
if (!table) {
return notFound(res, 'Table');
}
// Check ownership or admin role
const isOwner = table.owner_id === userId;
const isAdmin = req.user?.role === 'admin' || req.user?.role === 'owner';
if (!isOwner && !isAdmin) {
return forbidden(res, 'You do not have permission to delete this table');
}
// Prevent deletion of system tables
if (table.is_system) {
return forbidden(res, 'Cannot delete system table');
}
// Delete table (CASCADE will delete columns and rows)
await dbRun('DELETE FROM universal_tables WHERE id = ?', [tableId]);
apiLogger.info({ tableId, tableName: table.name, userId }, 'Table deleted');
success(res, { message: 'Table deleted successfully', tableId: parseInt(tableId) });
} catch (err) {
apiLogger.error({ err }, 'DELETE /tables/:tableId error');
error(res, 'DELETE_TABLE_FAILED', err.message, 500);
}
});
/**
* ADR-0034 §7 — Booking exclusion-constraint admin endpoints.
*
* GET /api/v3/tables/:tableId/booking-constraint → current config or null
* POST /api/v3/tables/:tableId/booking-constraint → enable / replace
* body: { lane_column, start_column, end_column }
* DELETE /api/v3/tables/:tableId/booking-constraint → disable
*/
router.get('/tables/:tableId/booking-constraint', async (req, res) => {
try {
const cfg = await getBookingConstraint(req.params.tableId);
success(res, cfg);
} catch (err) {
apiLogger.error({ err }, 'GET /tables/:tableId/booking-constraint error');
error(res, 'BOOKING_CONSTRAINT_FETCH_FAILED', err.message, 500);
}
});
router.post('/tables/:tableId/booking-constraint', async (req, res) => {
try {
const { tableId } = req.params;
const { lane_column, start_column, end_column } = req.body || {};
if (!lane_column || !start_column || !end_column) {
return badRequest(res, 'lane_column, start_column, end_column are required');
}
const cfg = await enableBookingConstraint({
table_id: tableId,
lane_column,
start_column,
end_column,
});
success(res, cfg, 'Booking constraint enabled');
} catch (err) {
if (err.code === 'BOOKING_CONSTRAINT_EXISTING_OVERLAP') {
// ADR-0034 §7 — operator must scrub existing overlaps before enabling.
return res.status(400).json({
success: false,
error: 'BOOKING_CONSTRAINT_EXISTING_OVERLAP',
message: err.message,
});
}
if (typeof err.message === 'string' && /booking-constraint:/.test(err.message)) {
// Identifier validation rejection (assertSafeColumn / assertTableId).
return badRequest(res, err.message);
}
apiLogger.error({ err }, 'POST /tables/:tableId/booking-constraint error');
error(res, 'BOOKING_CONSTRAINT_ENABLE_FAILED', err.message, 500);
}
});
router.delete('/tables/:tableId/booking-constraint', async (req, res) => {
try {
await disableBookingConstraint(req.params.tableId);
success(res, null, 'Booking constraint disabled');
} catch (err) {
apiLogger.error({ err }, 'DELETE /tables/:tableId/booking-constraint error');
error(res, 'BOOKING_CONSTRAINT_DISABLE_FAILED', err.message, 500);
}
});
export default router;