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

342 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;