Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
/**
|
||
* Table row create controller
|
||
* Handles: POST /tables/:tableId/rows
|
||
*/
|
||
import express from 'express';
|
||
import bcrypt from 'bcrypt';
|
||
import { dbAll, dbGet, dbRun, toBool, sqlNow } from '../../../database/connection.js';
|
||
import { generateBaseId } from '../../../utils/baseId.js';
|
||
import { generatePersonalKey, encryptPersonalKey } from '../../../services/AuthService.js';
|
||
import { apiLogger } from '../../../utils/logger.js';
|
||
import { success, created, notFound, badRequest, forbidden, error } from '../../../utils/response.js';
|
||
import { fireRowCreateTriggers } from '../../../services/AutomationTriggerService.js';
|
||
import { onDocumentTableMutation } from '../../../services/documents/SnapshotWriter.js';
|
||
import { onCriterionChange as onBddCriterionChange } from '../../../services/bdd/completionGate.js';
|
||
import { resolveSelectValues } from '../../../services/SelectValueResolver.js';
|
||
import { coerceDataObject } from '../../../services/agent-tools/coerceDataInput.js';
|
||
import { checkTableAccess } from './helpers.js';
|
||
import { validateVerificationSettingsAtom } from '../../../services/verification/applyOverrideValidator.js';
|
||
import { validateTicketRefAtom } from '../../../services/atoms/ticket-ref-serializer.js';
|
||
import { validateWidgetAtomRecursion } from '../../../services/atoms/widget-atom-recursion-guard.js';
|
||
import { ATOMS_V2_TABLE_ID } from '../../../services/atoms-archive.js';
|
||
import { getWidgetById } from '../../../services/WidgetService.js';
|
||
import { isBookingConflictError, findConflictingRowId } from '../../../lib/booking-constraint.js';
|
||
import { writeAudit } from '../../../services/audit/writeAudit.js';
|
||
|
||
const router = express.Router();
|
||
|
||
/**
|
||
* POST /api/v3/tables/:tableId/rows
|
||
* Create a new row in a table (supports external data sources)
|
||
*/
|
||
router.post('/tables/:tableId/rows', async (req, res) => {
|
||
try {
|
||
const { tableId } = req.params;
|
||
let { data } = req.body;
|
||
|
||
try { data = coerceDataObject(data, 'data') || {}; }
|
||
catch (e) { return badRequest(res, e.message); }
|
||
|
||
if (req.user?.projectId) {
|
||
const access = await checkTableAccess(tableId, req.user);
|
||
if (!access.allowed) {
|
||
return forbidden(res, access.error);
|
||
}
|
||
}
|
||
|
||
apiLogger.debug({ tableId, data }, 'POST row request');
|
||
|
||
const table = await dbGet(`
|
||
SELECT data_source_id, source_table_name, source_id_column, is_system, sync_target
|
||
FROM universal_tables
|
||
WHERE id = ?
|
||
`, [tableId]);
|
||
|
||
if (!table) {
|
||
return notFound(res, 'Table');
|
||
}
|
||
|
||
// Handle system tables (users, projects, etc.)
|
||
if (table.is_system && table.sync_target) {
|
||
// ADR-0012 Phase 8.2 — Widgets is a read-only template registry.
|
||
// Creation goes through POST /api/v3/widgets which sets owner correctly.
|
||
if (table.sync_target === 'widgets') {
|
||
return forbidden(res, 'Creating widgets via system table is not supported. Use POST /api/v3/widgets.');
|
||
}
|
||
|
||
if (table.sync_target === 'users') {
|
||
const columns = await dbAll(`
|
||
SELECT id, column_name FROM table_columns WHERE table_id = ?
|
||
`, [tableId]);
|
||
|
||
const idToName = {};
|
||
const nameToId = {};
|
||
columns.forEach(col => {
|
||
idToName[col.id] = col.column_name;
|
||
nameToId[col.column_name] = col.id;
|
||
});
|
||
|
||
const normalizedData = {};
|
||
const dataEntries = Object.entries(data || {});
|
||
|
||
// First pass: column NAMES
|
||
dataEntries.forEach(([key, value]) => {
|
||
if (nameToId[key]) {
|
||
normalizedData[key] = value;
|
||
}
|
||
});
|
||
|
||
// Second pass: column IDs (override)
|
||
dataEntries.forEach(([key, value]) => {
|
||
if (idToName[key]) {
|
||
const columnName = idToName[key];
|
||
normalizedData[columnName] = value;
|
||
}
|
||
});
|
||
|
||
apiLogger.debug({ normalizedData }, 'POST users normalizedData');
|
||
|
||
const { email, name, role, password_hash } = normalizedData;
|
||
|
||
if (!email || !name) {
|
||
return badRequest(res, 'Email and name are required');
|
||
}
|
||
|
||
let hashedPassword = null;
|
||
if (password_hash && password_hash !== '••••••••') {
|
||
hashedPassword = await bcrypt.hash(password_hash, 10);
|
||
} else {
|
||
return badRequest(res, 'Password is required for new users');
|
||
}
|
||
|
||
const personalKey = generatePersonalKey();
|
||
const encryption_key_encrypted = encryptPersonalKey(personalKey);
|
||
|
||
const result = await dbRun(`
|
||
INSERT INTO users (email, name, role, password_hash, encryption_key_encrypted, email_verified, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
||
`, [email, name, role || 'user', hashedPassword, encryption_key_encrypted, toBool(false)]);
|
||
|
||
return created(res, { id: result.lastInsertRowid || result.lastID }, 'User created successfully');
|
||
}
|
||
|
||
return forbidden(res, `Creating rows in ${table.sync_target} table is not yet supported`);
|
||
}
|
||
|
||
// If external table, insert into MySQL
|
||
if (table.data_source_id && table.source_table_name) {
|
||
const DataSourceService = (await import('../../../services/DataSourceService.js')).default;
|
||
const dataSourceService = new DataSourceService();
|
||
const dataSource = await dataSourceService.get(table.data_source_id);
|
||
|
||
if (dataSource.type === 'local_mysql') {
|
||
const mysql = await import('mysql2/promise');
|
||
const connection = await mysql.createConnection({
|
||
host: dataSource.db_host,
|
||
port: dataSource.db_port,
|
||
database: dataSource.db_name,
|
||
user: dataSource.db_username,
|
||
password: ''
|
||
});
|
||
|
||
try {
|
||
const fields = Object.keys(data);
|
||
const values = Object.values(data);
|
||
const placeholders = fields.map(() => '?').join(', ');
|
||
const fieldNames = fields.map(f => `\`${f}\``).join(', ');
|
||
|
||
const [result] = await connection.execute(
|
||
`INSERT INTO \`${table.source_table_name}\` (${fieldNames}) VALUES (${placeholders})`,
|
||
values
|
||
);
|
||
|
||
await connection.end();
|
||
|
||
return created(res, { id: result.insertId }, 'Row created successfully');
|
||
} catch (mysqlError) {
|
||
await connection.end();
|
||
throw mysqlError;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2026-04-27: silent floor on `order` for doc-content tables (`doc_*`).
|
||
// Frontend regression let `order` become fractional (.5) which broke
|
||
// `ORDER BY (data->>'order')::integer` in document content fetch.
|
||
// Never throw — priority is "document doesn't break".
|
||
if (data && data.order != null && Number.isFinite(Number(data.order))) {
|
||
const tableName = await dbGet(`SELECT name FROM universal_tables WHERE id = ?`, [tableId]);
|
||
if (tableName?.name && tableName.name.startsWith('doc_')) {
|
||
data.order = Math.floor(Number(data.order));
|
||
}
|
||
}
|
||
|
||
// Local table - insert into table_rows
|
||
let { resolvedData: validatedData, errors: selectErrors } = await resolveSelectValues(tableId, data);
|
||
if (selectErrors.length > 0) {
|
||
apiLogger.warn({ selectErrors }, 'Select column validation errors on create');
|
||
return badRequest(res, `Invalid select values: ${selectErrors.join('; ')}`);
|
||
}
|
||
|
||
// ADR-0011 Phase E2: tighten-only validation for verification_settings
|
||
// override atoms (atoms_v2 only). No-op for unrelated tables/atoms.
|
||
const overrideCheck = await validateVerificationSettingsAtom({
|
||
tableId: parseInt(tableId),
|
||
data: validatedData,
|
||
});
|
||
if (!overrideCheck.ok) {
|
||
apiLogger.warn({ tableId, field: overrideCheck.field, error: overrideCheck.error },
|
||
'verification_settings override rejected (CREATE)');
|
||
return error(res, 'VERIFICATION_OVERRIDE_REJECTED', overrideCheck.error, overrideCheck.status || 400);
|
||
}
|
||
|
||
// ADR-0012 §Phase 5: validate ticket_ref atoms and hydrate snapshot when
|
||
// mode != 'live'. No-op for unrelated tables/atoms.
|
||
const ticketRefCheck = await validateTicketRefAtom({
|
||
tableId: parseInt(tableId),
|
||
data: validatedData,
|
||
atomsV2TableId: ATOMS_V2_TABLE_ID,
|
||
loadWidget: getWidgetById,
|
||
loadTicket: async (ticketsTableId, ticketId) => {
|
||
return dbGet(
|
||
`SELECT id, base_id, table_id, data, created_at, updated_at
|
||
FROM table_rows
|
||
WHERE id = ? AND table_id = ?`,
|
||
[ticketId, ticketsTableId]
|
||
);
|
||
},
|
||
});
|
||
if (!ticketRefCheck.ok) {
|
||
apiLogger.warn({ tableId, field: ticketRefCheck.field, error: ticketRefCheck.error },
|
||
'ticket_ref atom rejected (CREATE)');
|
||
return error(res, ticketRefCheck.code || 'TICKET_REF_INVALID', ticketRefCheck.error, ticketRefCheck.status || 400);
|
||
}
|
||
validatedData = ticketRefCheck.data;
|
||
|
||
// ADR-0005 §C-12: block one-level recursive `documents` widget embedding.
|
||
// No-op for non-atoms_v2 tables and for atoms that don't carry both
|
||
// `widget_ref` + `document_id`.
|
||
const recursionCheck = await validateWidgetAtomRecursion({
|
||
tableId: parseInt(tableId),
|
||
data: validatedData,
|
||
atomsV2TableId: ATOMS_V2_TABLE_ID,
|
||
loadWidget: getWidgetById,
|
||
loadDocumentRegistryId: async (documentId) => {
|
||
// Find the registry table that contains a row with id=documentId AND
|
||
// has a documents-widget pointing at it. Falls through to the row's
|
||
// own table_id when it lives in any universal_tables row.
|
||
const row = await dbGet(
|
||
`SELECT table_id FROM table_rows WHERE id = ?`,
|
||
[documentId]
|
||
);
|
||
return row && Number.isInteger(Number(row.table_id)) ? Number(row.table_id) : null;
|
||
},
|
||
});
|
||
if (!recursionCheck.ok) {
|
||
apiLogger.warn(
|
||
{ tableId, widget_id: recursionCheck.widget_id, document_id: recursionCheck.document_id },
|
||
'recursive_document_embedding atom rejected (CREATE)'
|
||
);
|
||
return res.status(recursionCheck.status || 400).json({
|
||
success: false,
|
||
error: recursionCheck.code,
|
||
message: recursionCheck.error,
|
||
widget_id: recursionCheck.widget_id,
|
||
document_id: recursionCheck.document_id,
|
||
});
|
||
}
|
||
|
||
const base_id = generateBaseId();
|
||
const userId = req.user?.id || null;
|
||
|
||
const result = await dbRun(`
|
||
INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ${sqlNow()}, ${sqlNow()})
|
||
`, [tableId, base_id, JSON.stringify(validatedData), userId]);
|
||
|
||
const newRowId = result.lastInsertRowid;
|
||
fireRowCreateTriggers(parseInt(tableId), newRowId, validatedData).catch(err => {
|
||
apiLogger.warn({ err, tableId, rowId: newRowId }, 'Row create trigger failed (non-blocking)');
|
||
});
|
||
|
||
// ADR-0003 C-12: schedule a debounced FS snapshot if this row belongs to
|
||
// a documents_registry or document_content table (atom add → markdown
|
||
// changed). Fire-and-forget.
|
||
onDocumentTableMutation(parseInt(tableId), newRowId, 'update');
|
||
|
||
// ADR-0002 §8 Phase 3 (G6) — recompute Tickets.criteria_progress when a
|
||
// new bdd_criteria row was just inserted with a ticket_id. Fire-and-forget.
|
||
if (Number(tableId) === 7256) {
|
||
Promise.resolve().then(() => onBddCriterionChange(null, validatedData)).catch(() => {});
|
||
}
|
||
|
||
// ADR-0066 P1 — fire-and-forget audit of the row create. Never awaited.
|
||
void writeAudit(req, {
|
||
action: 'row.create',
|
||
entity_type: 'table_row',
|
||
entity_id: newRowId,
|
||
details: { table_id: Number(tableId), new_data: validatedData },
|
||
});
|
||
|
||
const newRow = await dbGet(
|
||
`SELECT id, table_id, base_id, data, created_by, created_at, updated_at FROM table_rows WHERE id = ?`,
|
||
[newRowId]
|
||
);
|
||
created(res, newRow || { id: newRowId, base_id, data: validatedData, created_by: userId }, 'Row created successfully');
|
||
} catch (err) {
|
||
apiLogger.error({ err }, 'POST /tables/:tableId/rows error');
|
||
|
||
// ADR-0034 §7 — booking-constraint exclusion violation → 409 with
|
||
// conflicting_row_id so optimistic UI can rollback + toast.
|
||
if (isBookingConflictError(err)) {
|
||
try {
|
||
const conflicting_row_id = await findConflictingRowId({
|
||
table_id: req.params.tableId,
|
||
data: req.body?.data || {},
|
||
});
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: 'slot_taken',
|
||
message: 'Slot already taken — refresh',
|
||
conflicting_row_id,
|
||
});
|
||
} catch (lookupErr) {
|
||
apiLogger.warn({ err: lookupErr }, 'booking-constraint conflict lookup failed; returning 409 without row id');
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: 'slot_taken',
|
||
message: 'Slot already taken — refresh',
|
||
conflicting_row_id: null,
|
||
});
|
||
}
|
||
}
|
||
|
||
let userMessage = err.message;
|
||
let errorCode = 'ROW_CREATE_FAILED';
|
||
let statusCode = 500;
|
||
|
||
// PostgreSQL unique violation
|
||
if (err.code === '23505') {
|
||
statusCode = 400;
|
||
errorCode = 'UNIQUE_CONSTRAINT_VIOLATION';
|
||
const match = err.detail?.match(/Key \((\w+)\)=/);
|
||
const fieldName = match ? match[1] : 'поле';
|
||
const fieldLabels = {
|
||
email: 'Email',
|
||
name: 'Имя',
|
||
id: 'ID'
|
||
};
|
||
const displayName = fieldLabels[fieldName] || fieldName;
|
||
userMessage = `${displayName} должен быть уникальным. Это значение уже используется.`;
|
||
} else if (err.code === '23502') {
|
||
// PostgreSQL not-null violation
|
||
statusCode = 400;
|
||
errorCode = 'REQUIRED_FIELD_MISSING';
|
||
userMessage = 'Не заполнены обязательные поля';
|
||
}
|
||
|
||
error(res, errorCode, userMessage, statusCode);
|
||
}
|
||
});
|
||
|
||
export default router;
|