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

272 lines
8.5 KiB
JavaScript

/**
* Table row batch operations controller
* Handles: POST /tables/:tableId/rows/batch-update,
* POST /tables/:tableId/rows/batch-delete
*/
import express from 'express';
import { dbAll, dbGet, dbRun, sqlNow, safeJsonParse } from '../../../database/connection.js';
import { apiLogger } from '../../../utils/logger.js';
import { success, notFound, badRequest, forbidden, error } from '../../../utils/response.js';
import { resolveSelectValues } from '../../../services/SelectValueResolver.js';
import { checkTableAccess } from './helpers.js';
import { fireRowUpdateTriggers } from '../../../services/AutomationTriggerService.js';
const router = express.Router();
/**
* POST /api/v3/tables/:tableId/rows/batch-update
* Batch update multiple rows at once
* Useful for bulk replace and mass updates
*/
router.post('/tables/:tableId/rows/batch-update', async (req, res) => {
try {
const { tableId } = req.params;
const { updates } = req.body;
// Validate input
if (!Array.isArray(updates) || updates.length === 0) {
return badRequest(res, 'Updates array is required and must not be empty');
}
// Limit batch size for safety (single transactional batch)
const MAX_BATCH_SIZE = 10000;
if (updates.length > MAX_BATCH_SIZE) {
return error(
res,
'BATCH_LIMIT_EXCEEDED',
`Batch size exceeds limit of ${MAX_BATCH_SIZE}`,
400,
{ limit: MAX_BATCH_SIZE, requested: updates.length }
);
}
apiLogger.debug({ tableId, count: updates.length }, "BATCH UPDATE request");
// 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);
}
}
// Get table info
const table = await dbGet(`
SELECT data_source_id, source_table_name, source_id_column
FROM universal_tables
WHERE id = ?
`, [tableId]);
if (!table) {
return notFound(res, 'Table');
}
// Get columns for this table
const columns = await dbAll(`
SELECT id, column_name
FROM table_columns
WHERE table_id = ?
`, [tableId]);
// Build column ID to name map
const columnIdToName = {};
for (const col of columns) {
columnIdToName[col.id] = col.column_name;
columnIdToName[String(col.id)] = col.column_name;
}
const results = {
success: true,
updated: 0,
errors: []
};
// ADR-0025 A.2: row_update triggers are collected during the transaction
// and fired after COMMIT so they observe persisted state.
const pendingTriggers = [];
// Process updates in a transaction
await dbRun('BEGIN TRANSACTION');
try {
for (const update of updates) {
const { rowId, data } = update;
if (!rowId || !data) {
results.errors.push({ rowId: String(rowId), error: 'Missing rowId or data' });
continue;
}
try {
// Handle local table_rows
if (!table.data_source_id || !table.source_table_name) {
// Get existing row
const existingRow = await dbGet(
'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?',
[rowId, tableId]
);
if (!existingRow) {
results.errors.push({ rowId: String(rowId), error: 'Row not found' });
continue;
}
// Map column_id keys to column_name keys
const normalizedData = {};
for (const [key, value] of Object.entries(data)) {
if (columnIdToName[key]) {
normalizedData[columnIdToName[key]] = value;
} else {
normalizedData[key] = value;
}
}
// ADR-098: Resolve select column text values -> numeric IDs before merge
const { resolvedData: validatedBatchData } = await resolveSelectValues(tableId, normalizedData);
// Merge data
const existingData = safeJsonParse(existingRow.data) || {};
const mergedData = { ...existingData, ...validatedBatchData };
await dbRun(
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
[JSON.stringify(mergedData), rowId]
);
pendingTriggers.push({ rowId: existingRow.id, oldData: existingData, newData: mergedData });
results.updated++;
} else {
// Handle external data source - save to local table_rows as virtual data
const existingRow = await dbGet(
'SELECT id, data FROM table_rows WHERE table_id = ? AND base_id = ?',
[tableId, rowId]
);
if (existingRow) {
const existingData = safeJsonParse(existingRow.data) || {};
const mergedData = { ...existingData, ...data };
await dbRun(
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
[JSON.stringify(mergedData), existingRow.id]
);
} else {
await dbRun(
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
[tableId, rowId, JSON.stringify(data)]
);
}
results.updated++;
}
} catch (rowError) {
apiLogger.error({ err: rowError, rowId }, 'BATCH UPDATE row error');
results.errors.push({ rowId: String(rowId), error: rowError.message });
}
}
await dbRun('COMMIT');
} catch (txError) {
await dbRun('ROLLBACK');
throw txError;
}
for (const t of pendingTriggers) {
fireRowUpdateTriggers(parseInt(tableId), t.rowId, t.newData, t.oldData).catch(err => {
apiLogger.warn({ err, tableId, rowId: t.rowId }, 'Batch row update trigger failed (non-blocking)');
});
}
apiLogger.info({ updated: results.updated, errors: results.errors.length }, "BATCH UPDATE completed");
success(res, {
updated: results.updated,
errors: results.errors.length > 0 ? results.errors : undefined
});
} catch (err) {
apiLogger.error({ err }, 'POST batch-update error');
error(res, 'BATCH_UPDATE_FAILED', err.message, 500);
}
});
/**
* POST /api/v3/tables/:tableId/rows/batch-delete
* Batch delete multiple rows at once
* Body: { rowIds: string[] }
*/
router.post('/tables/:tableId/rows/batch-delete', async (req, res) => {
try {
const { tableId } = req.params;
const { rowIds } = req.body;
// Validate input
if (!Array.isArray(rowIds) || rowIds.length === 0) {
return badRequest(res, 'rowIds array is required and must not be empty');
}
// Limit batch size for safety (single transactional batch)
const MAX_BATCH_SIZE = 10000;
if (rowIds.length > MAX_BATCH_SIZE) {
return error(
res,
'BATCH_LIMIT_EXCEEDED',
`Batch size exceeds limit of ${MAX_BATCH_SIZE}`,
400,
{ limit: MAX_BATCH_SIZE, requested: rowIds.length }
);
}
apiLogger.debug({ tableId, count: rowIds.length }, "BATCH DELETE request");
// 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);
}
}
// Get table info
const table = await dbGet(`
SELECT id, data_source_id, source_table_name
FROM universal_tables
WHERE id = ?
`, [tableId]);
if (!table) {
return notFound(res, 'Table');
}
// Only support local tables for batch delete
if (table.data_source_id) {
return badRequest(res, 'Batch delete is not supported for external data sources');
}
// Perform batch delete in transaction
await dbRun('BEGIN TRANSACTION');
try {
const placeholders = rowIds.map(() => '?').join(',');
const result = await dbRun(
`DELETE FROM table_rows WHERE table_id = ? AND id IN (${placeholders})`,
[tableId, ...rowIds]
);
await dbRun('COMMIT');
apiLogger.info({ deleted: result.changes }, "BATCH DELETE completed");
success(res, { deleted: result.changes });
} catch (txError) {
await dbRun('ROLLBACK');
throw txError;
}
} catch (err) {
apiLogger.error({ err }, 'POST batch-delete error');
error(res, 'BATCH_DELETE_FAILED', err.message, 500);
}
});
export default router;