Refresh of the open-core distribution from the private tree. Included since the previous snapshot: - Mail module (ADR-158/159/160/169): composer, labels, scheduling, attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083 - Crawler-readable SSR for /blog and public spaces (ADR-190): blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages - Registration policy + referral/promo settings (ADR-183/188) - Message translation + language detection (ADR-185) - Reddit connector for the agent-tool surface Excised from the public distribution (unchanged policy): infrastructure topology and host config, internal ops scripts, DB cleanup snapshots, business documents, throwaway debug scripts, and two private product lines (SC-SIM simulator, personal one-off tools). Real host addresses are replaced with placeholders; credential-shaped literals are redacted. Frontend build verified green on this tree.
841 lines
34 KiB
JavaScript
841 lines
34 KiB
JavaScript
/**
|
|
* Table row update/delete controller
|
|
* Handles: PUT /tables/:tableId/rows/:rowId,
|
|
* DELETE /tables/:tableId/rows/:rowId
|
|
*/
|
|
import express from 'express';
|
|
import bcrypt from 'bcrypt';
|
|
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 { coerceDataObject } from '../../../services/agent-tools/coerceDataInput.js';
|
|
import { checkTableAccess } from './helpers.js';
|
|
import { applyAtomVersioning, isAtomsV2Table, ATOMS_V2_TABLE_ID } from '../../../services/atoms-archive.js';
|
|
import { fireRowUpdateTriggers } from '../../../services/AutomationTriggerService.js';
|
|
import { emitRowMutationEvents } from '../../../services/tableMutationService.js';
|
|
import { onDocumentTableMutation, captureDocumentContext, onDocumentStatusTransition } from '../../../services/documents/SnapshotWriter.js';
|
|
import { onTicketStateTransition } from '../../../services/bdd/regressionWatcher.js';
|
|
import { checkCompletionGate, formatGateError, onCriterionChange, COMPLETION_GATE_DONE_STATE } from '../../../services/bdd/completionGate.js';
|
|
import { enforceVerificationGuards } from '../../../services/verification/guards.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 { getWidgetById } from '../../../services/WidgetService.js';
|
|
import { isBookingConflictError, findConflictingRowId } from '../../../lib/booking-constraint.js';
|
|
import { writeAudit } from '../../../services/audit/writeAudit.js';
|
|
import { computeRowDiff } from '../../../services/audit/rowDiff.js';
|
|
import { applyMailMessageWrites, extractMailRowId, MailWriteError } from '../../../services/mail/messageWrites.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* PUT /api/v3/tables/:tableId/rows/:rowId
|
|
* Update a row in a table (supports external data sources)
|
|
*/
|
|
router.put('/tables/:tableId/rows/:rowId', async (req, res) => {
|
|
try {
|
|
const { tableId, rowId } = 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({ rowId }, 'UPDATE received rowId');
|
|
apiLogger.debug({ dataKeys: Object.keys(data || {}) }, 'UPDATE received data keys');
|
|
|
|
const table = await dbGet(`
|
|
SELECT data_source_id, source_table_name, source_id_column, is_system, sync_target, project_id
|
|
FROM universal_tables
|
|
WHERE id = ?
|
|
`, [tableId]);
|
|
|
|
if (!table) {
|
|
return notFound(res, 'Table');
|
|
}
|
|
|
|
// ADR-175 P1 §write-dispatch — the mail facade is now writable from the grid.
|
|
// Field edits (is_read/starred/folder/linkage) dispatch to the ONE mail-write
|
|
// authority (applyMailMessageWrites), which validates + applies the same
|
|
// space-scoped UPDATE the Mail module's PATCH/link routes use. table_rows is
|
|
// never touched (B2 invariant). Label add/remove and assignment are relational
|
|
// (separate endpoints / §6/§7), not field patches — they stay out of this path.
|
|
// PES is intentionally NOT fired here (Open Question #4 unresolved).
|
|
if (table.is_system && table.sync_target === 'mail_messages') {
|
|
const project = await dbGet('SELECT space_id FROM projects WHERE id = ?', [table.project_id]);
|
|
const spaceId = project?.space_id;
|
|
if (!spaceId) return forbidden(res, 'Mail table is not bound to a space');
|
|
|
|
const msgId = extractMailRowId(rowId);
|
|
if (!Number.isFinite(msgId)) return badRequest(res, 'Invalid mail message id');
|
|
|
|
// Pick only writable mail fields from the incoming grid payload; ignore
|
|
// read-only cells (subject, from_address, …) the grid may echo back.
|
|
const patch = {};
|
|
for (const key of ['is_read', 'starred', 'folder', 'linked_table_id', 'linked_row_id']) {
|
|
if (key in data) patch[key] = data[key];
|
|
}
|
|
|
|
try {
|
|
const row = await applyMailMessageWrites({ spaceId, id: msgId, patch });
|
|
if (!row) return notFound(res, 'Message');
|
|
return success(res, null, 'Row updated successfully');
|
|
} catch (mailErr) {
|
|
if (mailErr instanceof MailWriteError) return badRequest(res, mailErr.message);
|
|
throw mailErr;
|
|
}
|
|
}
|
|
|
|
// If external table, update in data source
|
|
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);
|
|
|
|
// Handle INTERNAL data source (local database tables like users)
|
|
if (dataSource.type === 'internal') {
|
|
apiLogger.debug({ tableName: table.source_table_name }, 'Internal UPDATE table');
|
|
|
|
let realRowId = rowId;
|
|
if (rowId.startsWith('int_')) {
|
|
const parts = rowId.split('_');
|
|
realRowId = parts[parts.length - 1];
|
|
} else if (rowId.startsWith('user-')) {
|
|
realRowId = rowId.replace('user-', '');
|
|
}
|
|
apiLogger.debug({ realRowId }, 'Internal UPDATE real row ID');
|
|
|
|
const schemaColumns = await dbAll(`
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = $1
|
|
`, [table.source_table_name]);
|
|
const validColumnNames = new Set(schemaColumns.map(c => c.column_name));
|
|
|
|
const tableColumns = await dbAll(`
|
|
SELECT id, column_name
|
|
FROM table_columns
|
|
WHERE table_id = ?
|
|
`, [tableId]);
|
|
|
|
const columnIdToName = {};
|
|
for (const col of tableColumns) {
|
|
columnIdToName[col.id] = col.column_name;
|
|
columnIdToName[String(col.id)] = col.column_name;
|
|
}
|
|
|
|
apiLogger.debug({ columnIdToName }, 'Internal UPDATE column map');
|
|
apiLogger.debug({ dataKeys: Object.keys(data) }, 'Internal UPDATE data keys');
|
|
|
|
const updateData = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
const columnName = columnIdToName[key] || columnIdToName[String(key)] || key;
|
|
|
|
if (validColumnNames.has(columnName)) {
|
|
if (columnName === 'password_hash' && value && value !== '••••••••' && !value.startsWith('$2')) {
|
|
updateData[columnName] = await bcrypt.hash(value, 10);
|
|
apiLogger.debug({ columnName }, 'Hashed password for column');
|
|
} else if (columnName === 'password_hash' && (value === '••••••••' || !value)) {
|
|
apiLogger.debug('Skipping masked/empty password');
|
|
} else {
|
|
updateData[columnName] = value;
|
|
}
|
|
} else {
|
|
apiLogger.debug({ key, columnName }, 'Skipping invalid column');
|
|
}
|
|
}
|
|
|
|
apiLogger.debug({ updateDataKeys: Object.keys(updateData) }, 'Internal UPDATE data keys');
|
|
|
|
if (Object.keys(updateData).length === 0) {
|
|
return success(res, null, 'No changes to update');
|
|
}
|
|
|
|
const idColumn = table.source_id_column || 'id';
|
|
const updates = Object.keys(updateData)
|
|
.filter(key => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key))
|
|
.map(key => `"${key}" = ?`)
|
|
.join(', ');
|
|
const values = [...Object.values(updateData), realRowId];
|
|
|
|
if (!updates) {
|
|
return badRequest(res, 'No valid columns to update');
|
|
}
|
|
|
|
const sql = `UPDATE "${table.source_table_name}" SET ${updates}, updated_at = ${sqlNow()} WHERE "${idColumn}" = ?`;
|
|
apiLogger.debug({ sql }, 'Internal UPDATE SQL');
|
|
|
|
await dbRun(sql, values);
|
|
|
|
return success(res, null, 'Row updated successfully');
|
|
}
|
|
|
|
if (dataSource.type === 'local_mysql') {
|
|
let realRowId = rowId;
|
|
if (rowId.startsWith('ext_')) {
|
|
const parts = rowId.split('_');
|
|
realRowId = parts[parts.length - 1];
|
|
}
|
|
apiLogger.debug({ realRowId }, 'External UPDATE real MySQL row ID');
|
|
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 columns = await dbAll(`
|
|
SELECT id, column_name
|
|
FROM table_columns
|
|
WHERE table_id = ? AND is_from_source = 1
|
|
`, [tableId]);
|
|
|
|
const columnIdToName = {};
|
|
const validColumnNames = new Set();
|
|
for (const col of columns) {
|
|
columnIdToName[col.id] = col.column_name;
|
|
columnIdToName[String(col.id)] = col.column_name;
|
|
validColumnNames.add(col.column_name);
|
|
}
|
|
|
|
apiLogger.debug({ count: columns.length }, 'External UPDATE source columns count');
|
|
apiLogger.debug({ sample: Array.from(validColumnNames).slice(0, 10) }, 'External UPDATE valid column names sample');
|
|
|
|
const mappedData = {};
|
|
const skippedKeys = [];
|
|
for (const [key, value] of Object.entries(data)) {
|
|
const columnName = columnIdToName[key] || columnIdToName[String(key)];
|
|
if (columnName) {
|
|
mappedData[columnName] = value;
|
|
} else if (validColumnNames.has(key)) {
|
|
mappedData[key] = value;
|
|
} else {
|
|
skippedKeys.push(key);
|
|
}
|
|
}
|
|
|
|
apiLogger.debug({ skippedKeys }, 'External UPDATE skipped keys (virtual columns)');
|
|
apiLogger.debug({ mappedDataKeys: Object.keys(mappedData) }, 'External UPDATE mapped data keys');
|
|
|
|
// Handle virtual columns - save to local table_rows
|
|
if (skippedKeys.length > 0) {
|
|
const virtualData = {};
|
|
for (const key of skippedKeys) {
|
|
virtualData[key] = data[key];
|
|
}
|
|
|
|
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, ...virtualData };
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?, updated_at = ${sqlNow()} WHERE id = ?`,
|
|
[JSON.stringify(mergedData), existingRow.id]
|
|
);
|
|
apiLogger.debug({ virtualDataKeys: Object.keys(virtualData) }, 'Updated virtual columns in table_rows');
|
|
} else {
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at) VALUES (?, ?, ?, ${sqlNow()}, ${sqlNow()})`,
|
|
[tableId, rowId, JSON.stringify(virtualData)]
|
|
);
|
|
apiLogger.debug({ virtualDataKeys: Object.keys(virtualData) }, 'Created new table_rows entry for virtual columns');
|
|
}
|
|
}
|
|
|
|
if (Object.keys(mappedData).length === 0) {
|
|
await connection.end();
|
|
return success(res, null, 'Virtual columns updated successfully');
|
|
}
|
|
|
|
const idColumn = table.source_id_column || 'id';
|
|
const validEntries = Object.entries(mappedData)
|
|
.filter(([key]) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key));
|
|
|
|
if (validEntries.length === 0) {
|
|
await connection.end();
|
|
return badRequest(res, 'No valid columns to update');
|
|
}
|
|
|
|
const updates = validEntries
|
|
.map(([key]) => `\`${key}\` = ?`)
|
|
.join(', ');
|
|
const values = [...validEntries.map(([, val]) => val), realRowId];
|
|
|
|
apiLogger.debug({ sql: `UPDATE \`${table.source_table_name}\` SET ${updates} WHERE \`${idColumn}\` = ?` }, 'External UPDATE SQL');
|
|
apiLogger.debug({ realRowId }, 'External UPDATE realRowId');
|
|
|
|
const [result] = await connection.execute(
|
|
`UPDATE \`${table.source_table_name}\` SET ${updates} WHERE \`${idColumn}\` = ?`,
|
|
values
|
|
);
|
|
|
|
await connection.end();
|
|
|
|
return success(res, null, 'Row updated successfully');
|
|
} catch (mysqlError) {
|
|
await connection.end();
|
|
throw mysqlError;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local table - update in table_rows
|
|
const isNumericId = /^\d+$/.test(String(rowId));
|
|
|
|
let existingRow;
|
|
if (isNumericId) {
|
|
existingRow = await dbGet(`
|
|
SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?
|
|
`, [rowId, tableId]);
|
|
} else {
|
|
existingRow = await dbGet(`
|
|
SELECT id, data FROM table_rows WHERE base_id = ? AND table_id = ?
|
|
`, [rowId, tableId]);
|
|
}
|
|
|
|
if (!existingRow) {
|
|
return notFound(res, 'Row');
|
|
}
|
|
|
|
const actualRowId = existingRow.id;
|
|
|
|
// Map column_id keys to column_name keys (frontend may send either)
|
|
const tableColumns = await dbAll(`
|
|
SELECT id, column_name FROM table_columns WHERE table_id = ?
|
|
`, [tableId]);
|
|
|
|
const columnIdToName = {};
|
|
const columnNames = new Set();
|
|
for (const col of tableColumns) {
|
|
columnIdToName[col.id] = col.column_name;
|
|
columnIdToName[String(col.id)] = col.column_name;
|
|
columnNames.add(col.column_name);
|
|
}
|
|
|
|
const normalizedData = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (columnIdToName[key]) {
|
|
// Key is a column ID — map to column name
|
|
normalizedData[columnIdToName[key]] = value;
|
|
} else {
|
|
// Key is already a column name (or unknown) — keep as-is
|
|
normalizedData[key] = value;
|
|
}
|
|
}
|
|
|
|
// 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 (normalizedData.order != null && Number.isFinite(Number(normalizedData.order))) {
|
|
const tableInfo = await dbGet(`SELECT name FROM universal_tables WHERE id = ?`, [tableId]);
|
|
if (tableInfo?.name && tableInfo.name.startsWith('doc_')) {
|
|
normalizedData.order = Math.floor(Number(normalizedData.order));
|
|
}
|
|
}
|
|
|
|
apiLogger.debug({ originalKeys: Object.keys(data), normalizedKeys: Object.keys(normalizedData) }, 'UPDATE local table key normalization');
|
|
|
|
const { resolvedData: validatedData, errors: selectErrors } = await resolveSelectValues(tableId, normalizedData);
|
|
if (selectErrors.length > 0) {
|
|
apiLogger.warn({ selectErrors }, 'Select column validation errors — auto-resolved what we could');
|
|
return badRequest(res, `Invalid select values: ${selectErrors.join('; ')}`);
|
|
}
|
|
|
|
const existingData = safeJsonParse(existingRow.data) || {};
|
|
|
|
// ADR-0002 §8 Phase 3 (G4) — completion gate on Tickets (table 1708).
|
|
// If this PUT transitions state → done and any Must criterion is not yet
|
|
// verified, reject with 409 BEFORE any other mutation. No-op for non-1708
|
|
// tables and for transitions that do not target `done`.
|
|
const TICKETS_TABLE_ID_FOR_GATE = 1708;
|
|
if (Number(tableId) === TICKETS_TABLE_ID_FOR_GATE) {
|
|
const incomingState = Number(normalizedData.state);
|
|
const previousState = Number(existingData.state);
|
|
if (
|
|
Number.isFinite(incomingState) &&
|
|
incomingState === COMPLETION_GATE_DONE_STATE &&
|
|
previousState !== COMPLETION_GATE_DONE_STATE
|
|
) {
|
|
try {
|
|
const gate = await checkCompletionGate(actualRowId);
|
|
if (!gate.ok) {
|
|
apiLogger.info(
|
|
{ ticket_id: actualRowId, must_total: gate.must_total, must_verified: gate.must_verified, blocker_count: gate.blockers.length },
|
|
'ADR-0002 G4: completion gate blocked done transition (table_rows path)'
|
|
);
|
|
const body = formatGateError(gate);
|
|
return error(
|
|
res,
|
|
body.code,
|
|
`Cannot transition to 'done' — ${gate.blockers.length} of ${gate.must_total} must-criteria are not verified`,
|
|
409,
|
|
{ must_total: body.must_total, must_verified: body.must_verified, failed: body.failed }
|
|
);
|
|
}
|
|
} catch (gateErr) {
|
|
apiLogger.warn({ err: gateErr.message, ticket_id: actualRowId }, 'completion gate query failed (table_rows path), allowing transition');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR-0011 Phase B: verification-column guards (C-4 guard-violation, C-6
|
|
// status lock, immutability of verification cells via PUT). Runs on every
|
|
// local-table update; becomes a no-op when no verification columns exist.
|
|
const guardResult = await enforceVerificationGuards({
|
|
tableId: parseInt(tableId),
|
|
existingData,
|
|
incomingData: validatedData,
|
|
userId: req.user?.userId || req.user?.id || null,
|
|
});
|
|
if (!guardResult.ok) {
|
|
apiLogger.warn({ tableId, rowId: actualRowId, code: guardResult.code, meta: guardResult.meta }, 'Verification guard rejected row update');
|
|
return error(res, guardResult.code, guardResult.message, guardResult.status, guardResult.meta || null);
|
|
}
|
|
|
|
let mergedData = { ...existingData, ...validatedData, ...guardResult.cellOverrides };
|
|
apiLogger.debug('Merging existing data with new data');
|
|
apiLogger.debug({ existingKeys: Object.keys(existingData) }, 'Existing keys');
|
|
apiLogger.debug({ newKeys: Object.keys(validatedData) }, 'New keys');
|
|
apiLogger.debug({ mergedKeys: Object.keys(mergedData) }, 'Merged keys');
|
|
|
|
// 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: mergedData,
|
|
});
|
|
if (!overrideCheck.ok) {
|
|
apiLogger.warn({ tableId, rowId: actualRowId, field: overrideCheck.field, error: overrideCheck.error },
|
|
'verification_settings override rejected (UPDATE)');
|
|
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: mergedData,
|
|
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, rowId: actualRowId, field: ticketRefCheck.field, error: ticketRefCheck.error },
|
|
'ticket_ref atom rejected (UPDATE)');
|
|
return error(res, ticketRefCheck.code || 'TICKET_REF_INVALID', ticketRefCheck.error, ticketRefCheck.status || 400);
|
|
}
|
|
mergedData = 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: mergedData,
|
|
atomsV2TableId: ATOMS_V2_TABLE_ID,
|
|
loadWidget: getWidgetById,
|
|
loadDocumentRegistryId: async (documentId) => {
|
|
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, rowId: actualRowId, widget_id: recursionCheck.widget_id, document_id: recursionCheck.document_id },
|
|
'recursive_document_embedding atom rejected (UPDATE)'
|
|
);
|
|
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,
|
|
});
|
|
}
|
|
|
|
// ADR-0001 Wave 1: app-level versioning hook for atoms_v2 (table 3574).
|
|
// Snapshots the OLD row into atoms_archive and bumps `version`.
|
|
if (isAtomsV2Table(tableId)) {
|
|
try {
|
|
mergedData = await applyAtomVersioning({
|
|
table_id: tableId,
|
|
row_id: actualRowId,
|
|
newData: mergedData,
|
|
oldRow: { id: actualRowId, data: existingData },
|
|
changedByUser: req.user?.userId || req.user?.id || null,
|
|
changeReason: req.body?.change_reason || null,
|
|
});
|
|
} catch (hookErr) {
|
|
apiLogger.warn({ err: hookErr }, 'atoms_v2 versioning hook failed — UPDATE proceeds');
|
|
}
|
|
}
|
|
|
|
await dbRun(`
|
|
UPDATE table_rows
|
|
SET data = ?, updated_at = ${sqlNow()}
|
|
WHERE id = ? AND table_id = ?
|
|
`, [JSON.stringify(mergedData), actualRowId, tableId]);
|
|
|
|
// ADR-0025 A.2: fire row_update automations (e.g. archive → hidden=true).
|
|
// Watch-field gating in fireRowUpdateTriggers prevents action loops.
|
|
fireRowUpdateTriggers(parseInt(tableId), actualRowId, mergedData, existingData).catch(err => {
|
|
apiLogger.warn({ err, tableId, rowId: actualRowId }, 'Row update trigger failed (non-blocking)');
|
|
});
|
|
|
|
// ADR-0031 §A: row-mutation event log. Diffs old/new and posts system
|
|
// messages into the row's attached chat. Gated by ROW_MUTATION_LOG_ENABLED_SPACES
|
|
// env var (off by default; P3 flips on for space 11). Fire-and-forget.
|
|
emitRowMutationEvents({
|
|
tableId: parseInt(tableId),
|
|
rowId: actualRowId,
|
|
oldData: existingData,
|
|
newData: mergedData,
|
|
actor: {
|
|
id: req.user?.userId || req.user?.id || null,
|
|
name: req.user?.name || req.user?.username || null,
|
|
},
|
|
ctx: { suppress_mutation_log: req.context?.suppress_mutation_log === true },
|
|
}).catch(() => {});
|
|
|
|
// ADR-0003 C-12: schedule a debounced FS snapshot if this row belongs to
|
|
// a documents_registry or document_content table. Fire-and-forget.
|
|
onDocumentTableMutation(parseInt(tableId), actualRowId, 'update');
|
|
|
|
// ADR-0003 C-14: detect status → published transition on registry rows
|
|
// and write a canonical `_published.md` snapshot. Fire-and-forget.
|
|
onDocumentStatusTransition(parseInt(tableId), actualRowId, existingData, mergedData);
|
|
|
|
// ADR-0003 C-3: detect ticket state `done → non-done` and regress any
|
|
// verified BDD criteria linked to the reopened ticket. Fire-and-forget.
|
|
onTicketStateTransition(parseInt(tableId), actualRowId, existingData, mergedData);
|
|
|
|
// ADR-0002 §8 Phase 3 (G6) — recompute Tickets.criteria_progress when a
|
|
// bdd_criteria row was just mutated. Two cases handled:
|
|
// - bdd_criteria row update: recompute progress for the old AND new
|
|
// ticket_id (handles re-binding) and for status/priority flips.
|
|
// - Tickets row update: progress field stays in sync because we only
|
|
// change it via this very recompute path; nothing to do here.
|
|
if (Number(tableId) === 7256) {
|
|
// Fire-and-forget — we never want a progress recompute to fail the row write.
|
|
Promise.resolve().then(() => onCriterionChange(existingData, mergedData)).catch(() => {});
|
|
}
|
|
|
|
// ADR-0066 P1 — audit the row update with a per-column diff (only
|
|
// changed keys, before/after). No-op when nothing actually changed.
|
|
const auditDiff = computeRowDiff(existingData, mergedData);
|
|
if (auditDiff) {
|
|
void writeAudit(req, {
|
|
action: 'row.update',
|
|
entity_type: 'table_row',
|
|
entity_id: actualRowId,
|
|
details: { table_id: Number(tableId), ...auditDiff },
|
|
});
|
|
}
|
|
|
|
success(res, null, 'Row updated successfully');
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'PUT /tables/:tableId/rows/:rowId error');
|
|
|
|
// ADR-0034 §7 — booking-constraint exclusion violation → 409 with
|
|
// conflicting_row_id (excluding self).
|
|
if (isBookingConflictError(err)) {
|
|
try {
|
|
const { tableId, rowId } = req.params;
|
|
let selfId = /^\d+$/.test(String(rowId)) ? Number(rowId) : null;
|
|
if (selfId == null) {
|
|
const r = await dbGet(`SELECT id FROM table_rows WHERE base_id = ? AND table_id = ?`, [rowId, tableId]);
|
|
if (r) selfId = Number(r.id);
|
|
}
|
|
const conflicting_row_id = await findConflictingRowId({
|
|
table_id: tableId,
|
|
data: req.body?.data || {},
|
|
exclude_row_id: selfId,
|
|
});
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
error(res, 'ROW_UPDATE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v3/tables/:tableId/rows/:rowId
|
|
* Delete a row from a table (supports external data sources)
|
|
*/
|
|
router.delete('/tables/:tableId/rows/:rowId', async (req, res) => {
|
|
try {
|
|
const { tableId, rowId: rawRowId } = req.params;
|
|
|
|
let rowId = rawRowId;
|
|
if (typeof rawRowId === 'string' && rawRowId.includes('-')) {
|
|
const parts = rawRowId.split('-');
|
|
const lastPart = parts[parts.length - 1];
|
|
if (/^\d+$/.test(lastPart)) {
|
|
rowId = lastPart;
|
|
}
|
|
}
|
|
|
|
// Frontend may send the public `base_id` (e.g. "VGMSMF1A"); resolve it to
|
|
// the numeric primary key so downstream INT-typed queries don't blow up.
|
|
if (!/^\d+$/.test(String(rowId))) {
|
|
const r = await dbGet(
|
|
`SELECT id FROM table_rows WHERE base_id = ? AND table_id = ?`,
|
|
[rowId, tableId]
|
|
);
|
|
if (!r) {
|
|
return notFound(res, 'Row');
|
|
}
|
|
rowId = String(r.id);
|
|
}
|
|
|
|
if (req.user?.projectId) {
|
|
const access = await checkTableAccess(tableId, req.user);
|
|
if (!access.allowed) {
|
|
return forbidden(res, access.error);
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// ADR-0012 Phase 8.2 — Widgets is a read-only template registry.
|
|
// Deletion goes through the dedicated /api/v3/widgets/:id endpoint
|
|
// which has the proper atom_refs guard (see WidgetService.deleteWidget).
|
|
if (table.is_system && table.sync_target === 'widgets') {
|
|
return forbidden(res, 'Deleting widgets via system table is not supported. Use DELETE /api/v3/widgets/:id.');
|
|
}
|
|
|
|
// ADR-158 §P3 — mail_messages is read-only from the grid. Trashing a message
|
|
// (folder move) goes through the Mail module's PATCH API, not a row DELETE.
|
|
if (table.is_system && table.sync_target === 'mail_messages') {
|
|
return forbidden(res, 'Mail messages are read-only here. Use the Mail module to archive/delete.');
|
|
}
|
|
|
|
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);
|
|
|
|
// Handle INTERNAL data source (system tables backed by real Postgres tables
|
|
// like `users`). Without this branch, DELETE silently no-ops because it
|
|
// falls through to `DELETE FROM table_rows` which never matches.
|
|
if (dataSource.type === 'internal') {
|
|
let realRowId = rowId;
|
|
if (typeof rowId === 'string') {
|
|
if (rowId.startsWith('int_')) {
|
|
const parts = rowId.split('_');
|
|
realRowId = parts[parts.length - 1];
|
|
} else if (rowId.startsWith('user-')) {
|
|
realRowId = rowId.replace('user-', '');
|
|
}
|
|
}
|
|
|
|
const idColumn = table.source_id_column || 'id';
|
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(idColumn) ||
|
|
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table.source_table_name)) {
|
|
return badRequest(res, 'Invalid table configuration');
|
|
}
|
|
|
|
// Users table: soft-delete to preserve FK references (audit_log,
|
|
// agent_jobs, terminal_*, wa_*, tool_approval_rules ссылаются на
|
|
// users.id без ON DELETE CASCADE — hard DELETE упадёт). Email
|
|
// освобождается, чтобы можно было зарегистрироваться заново.
|
|
if (table.sync_target === 'users') {
|
|
const existing = await dbGet(
|
|
`SELECT id, status, email FROM "${table.source_table_name}" WHERE "${idColumn}" = ?`,
|
|
[realRowId]
|
|
);
|
|
if (!existing) {
|
|
return notFound(res, 'User');
|
|
}
|
|
if (existing.status === 'deleted') {
|
|
return success(res, null, 'User already deleted');
|
|
}
|
|
|
|
const tombstone = `deleted_${existing.id}_${Date.now()}@deleted.local`;
|
|
await dbRun(
|
|
`UPDATE "${table.source_table_name}"
|
|
SET status = 'deleted',
|
|
email = ?,
|
|
password_hash = 'DELETED',
|
|
email_verified = 0,
|
|
updated_at = ${sqlNow()}
|
|
WHERE "${idColumn}" = ?`,
|
|
[tombstone, realRowId]
|
|
);
|
|
|
|
void writeAudit(req, {
|
|
action: 'user.soft_delete',
|
|
entity_type: 'user',
|
|
entity_id: realRowId,
|
|
details: {
|
|
table_id: Number(tableId),
|
|
source_table: table.source_table_name,
|
|
freed_email: existing.email,
|
|
tombstone_email: tombstone,
|
|
},
|
|
});
|
|
|
|
return success(res, null, 'User soft-deleted (email released)');
|
|
}
|
|
|
|
// Non-users internal tables: hard delete; FK violations bubble up as 500.
|
|
await dbRun(
|
|
`DELETE FROM "${table.source_table_name}" WHERE "${idColumn}" = ?`,
|
|
[realRowId]
|
|
);
|
|
|
|
void writeAudit(req, {
|
|
action: 'row.delete',
|
|
entity_type: 'table_row',
|
|
entity_id: realRowId,
|
|
details: { table_id: Number(tableId), source_table: table.source_table_name },
|
|
});
|
|
|
|
return success(res, null, 'Row deleted successfully');
|
|
}
|
|
|
|
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 idColumn = table.source_id_column || 'id';
|
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(idColumn) ||
|
|
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table.source_table_name)) {
|
|
await connection.end();
|
|
return badRequest(res, 'Invalid table configuration');
|
|
}
|
|
const [result] = await connection.execute(
|
|
`DELETE FROM \`${table.source_table_name}\` WHERE \`${idColumn}\` = ?`,
|
|
[rowId]
|
|
);
|
|
|
|
await connection.end();
|
|
|
|
return success(res, null, 'Row deleted successfully');
|
|
} catch (mysqlError) {
|
|
await connection.end();
|
|
throw mysqlError;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR-0003 C-12/C-13: capture doc context BEFORE deletion (once the row
|
|
// is gone we can't resolve slug/title).
|
|
const preCapturedDocCtx = await captureDocumentContext(parseInt(tableId), parseInt(rowId));
|
|
|
|
// ADR-0002 §8 Phase 3 (G6) — capture pre-delete bdd_criteria data so we
|
|
// can recompute criteria_progress for the previously-linked ticket after
|
|
// the row is gone. No-op for non-7256 tables.
|
|
let preCapturedCriterionData = null;
|
|
if (Number(tableId) === 7256) {
|
|
const critRow = await dbGet(
|
|
`SELECT data FROM table_rows WHERE id = ? AND table_id = ?`,
|
|
[rowId, tableId]
|
|
);
|
|
if (critRow) {
|
|
preCapturedCriterionData = (typeof critRow.data === 'string')
|
|
? safeJsonParse(critRow.data, {})
|
|
: (critRow.data || {});
|
|
}
|
|
}
|
|
|
|
// ADR-0066 P1 — capture pre-delete row data so we can store it in the
|
|
// audit row. Done BEFORE the DELETE so we don't lose the payload.
|
|
const preDeletedRow = await dbGet(
|
|
`SELECT data FROM table_rows WHERE id = ? AND table_id = ?`,
|
|
[rowId, tableId]
|
|
);
|
|
const preDeletedData = preDeletedRow
|
|
? (typeof preDeletedRow.data === 'string'
|
|
? safeJsonParse(preDeletedRow.data, {})
|
|
: (preDeletedRow.data || {}))
|
|
: null;
|
|
|
|
// Local table - delete from table_rows
|
|
await dbRun(`
|
|
DELETE FROM table_rows
|
|
WHERE id = ? AND table_id = ?
|
|
`, [rowId, tableId]);
|
|
|
|
// ADR-0066 P1 — fire-and-forget audit of the row delete.
|
|
void writeAudit(req, {
|
|
action: 'row.delete',
|
|
entity_type: 'table_row',
|
|
entity_id: rowId,
|
|
details: { table_id: Number(tableId), deleted_data: preDeletedData },
|
|
});
|
|
|
|
// Fire snapshot trigger with pre-captured ctx (row is gone from DB now)
|
|
if (preCapturedDocCtx) {
|
|
onDocumentTableMutation(parseInt(tableId), parseInt(rowId), 'delete', preCapturedDocCtx);
|
|
}
|
|
|
|
// ADR-0002 §8 Phase 3 (G6) — recompute progress on the previously-linked
|
|
// ticket (must_total drops by 1 if it was a Must row).
|
|
if (preCapturedCriterionData) {
|
|
Promise.resolve().then(() => onCriterionChange(preCapturedCriterionData, null)).catch(() => {});
|
|
}
|
|
|
|
success(res, null, 'Row deleted successfully');
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /tables/:tableId/rows/:rowId error');
|
|
error(res, 'ROW_DELETE_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|