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.
433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
/**
|
|
* Table row list controller
|
|
* Handles: GET /tables/:tableId/rows (with pagination, filtering, search)
|
|
*/
|
|
import express from 'express';
|
|
import { dbAll, dbGet } from '../../../database/connection.js';
|
|
import { apiLogger } from '../../../utils/logger.js';
|
|
import { success, forbidden, error } from '../../../utils/response.js';
|
|
import { checkTableAccess } from './helpers.js';
|
|
import { buildOrderClause } from '../../../services/orderClause.js';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* GET /api/v3/tables/:tableId/rows
|
|
* Get rows for a table (with pagination)
|
|
* Supports external data sources - reads directly from MySQL if connected
|
|
* Supports filtering via ?filter={"column":"value"}
|
|
*/
|
|
router.get('/tables/:tableId/rows', async (req, res) => {
|
|
try {
|
|
const { tableId } = req.params;
|
|
const { page = 1, limit = 50, mode, filter, search, searchColumns } = req.query;
|
|
const rawMode = mode === 'raw';
|
|
const offset = (Number(page) - 1) * Number(limit);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Parse filter if provided
|
|
let filterObj = null;
|
|
if (filter) {
|
|
try { filterObj = JSON.parse(filter); } catch (e) { /* ignore invalid filter */ }
|
|
}
|
|
|
|
// Check if table has external data source or is a system table
|
|
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 system table, fetch from real database tables
|
|
if (table && table.is_system && table.sync_target) {
|
|
apiLogger.debug({ syncTarget: table.sync_target, projectId: table.project_id }, 'Reading from system table');
|
|
|
|
// ADR-175 P1 — mail is the only system table at business scale (100k+/account),
|
|
// so it uses a SQL push-down path (WHERE/ORDER/LIMIT/OFFSET in the DB) instead of
|
|
// the in-memory search/filter/slice() below used by the small system tables.
|
|
// Same response shape; correct totals + fast pages at scale.
|
|
if (table.sync_target === 'mail_messages') {
|
|
const { getMailMessagesPage } = await import('../../../services/SystemTableService.js');
|
|
const { rows, total } = await getMailMessagesPage(table.project_id, {
|
|
search, searchColumns, filter: filterObj, page, limit
|
|
});
|
|
return success(res, {
|
|
rows,
|
|
pagination: {
|
|
page: Number(page),
|
|
limit: Number(limit),
|
|
total,
|
|
pages: Math.ceil(total / Number(limit))
|
|
}
|
|
});
|
|
}
|
|
|
|
const { getSystemTableData } = await import('../../../services/SystemTableService.js');
|
|
let systemRowsResult = await getSystemTableData(table.sync_target, table.project_id);
|
|
|
|
// Apply search filter if provided
|
|
if (search) {
|
|
const searchLower = String(search).toLowerCase();
|
|
if (searchColumns) {
|
|
const cols = String(searchColumns).split(',').map(c => c.trim());
|
|
systemRowsResult = systemRowsResult.filter(row => {
|
|
const rowData = row.data && typeof row.data === 'object' ? row.data : row;
|
|
return cols.some(col => {
|
|
const val = rowData[col];
|
|
return val !== undefined && val !== null && String(val).toLowerCase().includes(searchLower);
|
|
});
|
|
});
|
|
} else {
|
|
systemRowsResult = systemRowsResult.filter(row =>
|
|
JSON.stringify(row).toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Apply column filter if provided (Bug #75036)
|
|
if (filterObj && Object.keys(filterObj).length > 0) {
|
|
systemRowsResult = systemRowsResult.filter(row => {
|
|
const rowData = row.data && typeof row.data === 'object' ? row.data : row;
|
|
return Object.entries(filterObj).every(([col, val]) => {
|
|
const cellValue = rowData[col];
|
|
if (cellValue === undefined || cellValue === null) return false;
|
|
return String(cellValue) === String(val);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Apply pagination
|
|
const start = offset;
|
|
const end = offset + Number(limit);
|
|
const paginatedRows = systemRowsResult.slice(start, end);
|
|
|
|
return success(res, {
|
|
rows: paginatedRows,
|
|
pagination: {
|
|
page: Number(page),
|
|
limit: Number(limit),
|
|
total: systemRowsResult.length,
|
|
pages: Math.ceil(systemRowsResult.length / Number(limit))
|
|
}
|
|
});
|
|
}
|
|
|
|
// If table is connected to external data source, read from there
|
|
if (table && table.data_source_id && table.source_table_name) {
|
|
apiLogger.debug({ dataSourceId: table.data_source_id, tableName: table.source_table_name }, 'Reading from data source');
|
|
const DataSourceService = (await import('../../../services/DataSourceService.js')).default;
|
|
const dataSourceService = new DataSourceService();
|
|
const dataSource = await dataSourceService.get(table.data_source_id);
|
|
|
|
// Internal data source - read from local database tables
|
|
if (dataSource.type === 'internal') {
|
|
apiLogger.debug({ tableName: table.source_table_name }, 'Reading from local database table');
|
|
|
|
const internalRows = await dbAll(
|
|
`SELECT * FROM "${table.source_table_name}" LIMIT ? OFFSET ?`,
|
|
[Number(limit), offset]
|
|
);
|
|
|
|
const countResult = await dbGet(
|
|
`SELECT COUNT(*) as total FROM "${table.source_table_name}"`
|
|
);
|
|
|
|
const total = countResult?.total || 0;
|
|
const pages = Math.ceil(total / Number(limit));
|
|
|
|
apiLogger.debug({ count: internalRows.length }, 'Internal rows fetched');
|
|
|
|
const columns = await dbAll('SELECT * FROM table_columns WHERE table_id = ?', [tableId]);
|
|
|
|
const idColumn = table.source_id_column || 'id';
|
|
const rows = internalRows.map((row, index) => {
|
|
const globalIndex = offset + index;
|
|
const internalId = row[idColumn];
|
|
const uniqueId = `int_${tableId}_${globalIndex}` + (internalId ? `_${internalId}` : '');
|
|
|
|
let data;
|
|
if (rawMode) {
|
|
data = { ...row };
|
|
} else {
|
|
data = {};
|
|
columns.forEach(col => {
|
|
if (row[col.column_name] !== undefined) {
|
|
data[col.id] = row[col.column_name];
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: uniqueId,
|
|
table_id: tableId,
|
|
base_id: internalId || uniqueId,
|
|
data,
|
|
created_by: 'internal',
|
|
created_at: row.created_at || new Date().toISOString(),
|
|
updated_at: row.updated_at || new Date().toISOString()
|
|
};
|
|
});
|
|
|
|
return success(res, {
|
|
rows,
|
|
pagination: {
|
|
page: Number(page),
|
|
limit: Number(limit),
|
|
total,
|
|
pages
|
|
}
|
|
});
|
|
}
|
|
|
|
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 {
|
|
// Build WHERE clause for filter
|
|
let whereClause = '';
|
|
let whereValues = [];
|
|
if (filterObj && Object.keys(filterObj).length > 0) {
|
|
const conditions = [];
|
|
for (const [col, val] of Object.entries(filterObj)) {
|
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(col)) {
|
|
apiLogger.warn({ col }, 'Invalid filter column name rejected');
|
|
continue;
|
|
}
|
|
conditions.push(`\`${col}\` = ?`);
|
|
whereValues.push(val);
|
|
}
|
|
if (conditions.length > 0) {
|
|
whereClause = 'WHERE ' + conditions.join(' AND ');
|
|
}
|
|
apiLogger.debug({ whereClause, whereValues }, 'MySQL filter WHERE');
|
|
}
|
|
|
|
const [mysqlRows] = await connection.execute(
|
|
`SELECT * FROM \`${table.source_table_name}\` ${whereClause} LIMIT ${Number(limit)} OFFSET ${offset}`,
|
|
whereValues
|
|
);
|
|
|
|
const [countResult] = await connection.execute(
|
|
`SELECT COUNT(*) as total FROM \`${table.source_table_name}\` ${whereClause}`,
|
|
whereValues
|
|
);
|
|
|
|
const total = countResult[0]?.total || 0;
|
|
const pages = Math.ceil(total / Number(limit));
|
|
|
|
const idColumn = table.source_id_column || 'id';
|
|
|
|
// Get virtual columns (is_from_source = 0) for this table
|
|
const virtualColumns = await dbAll(`
|
|
SELECT id, column_name, default_value
|
|
FROM table_columns
|
|
WHERE table_id = ? AND is_from_source = 0
|
|
`, [tableId]);
|
|
|
|
// Build map of base_id -> virtual data from table_rows
|
|
const rowIds = mysqlRows.map((row, index) => {
|
|
const globalIndex = offset + index;
|
|
const mysqlId = row[idColumn];
|
|
return `ext_${tableId}_${globalIndex}` + (mysqlId ? `_${mysqlId}` : '');
|
|
});
|
|
|
|
// Fetch all virtual data for these rows in one query
|
|
const virtualDataMap = {};
|
|
if (rowIds.length > 0 && virtualColumns.length > 0) {
|
|
const placeholders = rowIds.map(() => '?').join(',');
|
|
const virtualRows = await dbAll(
|
|
`SELECT base_id, data FROM table_rows WHERE table_id = ? AND base_id IN (${placeholders})`,
|
|
[tableId, ...rowIds]
|
|
);
|
|
for (const vr of virtualRows) {
|
|
try {
|
|
virtualDataMap[vr.base_id] = typeof vr.data === 'string' ? JSON.parse(vr.data) : vr.data;
|
|
} catch (e) {
|
|
virtualDataMap[vr.base_id] = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
const rows = mysqlRows.map((row, index) => {
|
|
const globalIndex = offset + index;
|
|
const mysqlId = row[idColumn];
|
|
const uniqueId = `ext_${tableId}_${globalIndex}` + (mysqlId ? `_${mysqlId}` : '');
|
|
|
|
const virtualData = virtualDataMap[uniqueId] || {};
|
|
const mergedData = { ...row };
|
|
|
|
for (const vc of virtualColumns) {
|
|
const colId = String(vc.id);
|
|
if (virtualData[colId] !== undefined) {
|
|
mergedData[colId] = virtualData[colId];
|
|
} else if (vc.default_value !== null && vc.default_value !== undefined) {
|
|
mergedData[colId] = vc.default_value;
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: uniqueId,
|
|
table_id: tableId,
|
|
data: mergedData,
|
|
originalId: mysqlId,
|
|
created_at: row.created_at || new Date().toISOString(),
|
|
updated_at: row.updated_at || new Date().toISOString()
|
|
};
|
|
});
|
|
|
|
await connection.end();
|
|
|
|
return success(res, {
|
|
rows,
|
|
pagination: {
|
|
page: Number(page),
|
|
limit: Number(limit),
|
|
total,
|
|
pages
|
|
}
|
|
});
|
|
} catch (mysqlError) {
|
|
await connection.end();
|
|
throw mysqlError;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to local table_rows
|
|
let whereClause = 'WHERE table_id = ?';
|
|
const queryParams = [tableId];
|
|
const countParams = [tableId];
|
|
|
|
if (search) {
|
|
const searchTerm = `%${String(search).toLowerCase()}%`;
|
|
if (searchColumns) {
|
|
const cols = String(searchColumns).split(',').map(c => c.trim());
|
|
const conditions = cols.map(col => `LOWER(data::jsonb->>'${col.replace(/'/g, "''")}') LIKE ?`);
|
|
whereClause += ` AND (${conditions.join(' OR ')})`;
|
|
cols.forEach(() => {
|
|
queryParams.push(searchTerm);
|
|
countParams.push(searchTerm);
|
|
});
|
|
} else {
|
|
whereClause += ` AND LOWER(data::text) LIKE ?`;
|
|
queryParams.push(searchTerm);
|
|
countParams.push(searchTerm);
|
|
}
|
|
}
|
|
|
|
if (filterObj && Object.keys(filterObj).length > 0) {
|
|
for (const [col, val] of Object.entries(filterObj)) {
|
|
if (!/^[a-zA-Z0-9_]+$/.test(col)) {
|
|
apiLogger.warn({ col }, 'Invalid filter column key rejected');
|
|
continue;
|
|
}
|
|
whereClause += ` AND (data::jsonb->>'${col.replace(/'/g, "''")}' = ? OR CAST(data::jsonb->>'${col.replace(/'/g, "''")}' AS TEXT) = ?)`;
|
|
queryParams.push(String(val), String(val));
|
|
countParams.push(String(val), String(val));
|
|
}
|
|
}
|
|
|
|
queryParams.push(Number(limit), offset);
|
|
|
|
// ADR-151 Slice A: if this table has a typed `order` column, default-sort
|
|
// ascending by it (NULLs/unset last), tie-broken by created_at DESC. Gated:
|
|
// tables with no `order` column keep the historical `ORDER BY created_at
|
|
// DESC` byte-for-byte. The numeric cast is regex-guarded so a dirty/empty
|
|
// cell can never throw and break the list (cf. the doc_* floor-guard ethos).
|
|
let orderByClause = 'ORDER BY created_at DESC';
|
|
const orderCol = await dbGet(
|
|
`SELECT column_name FROM table_columns
|
|
WHERE table_id = ? AND type = 'order'
|
|
ORDER BY order_index ASC, id ASC
|
|
LIMIT 1`,
|
|
[tableId]
|
|
);
|
|
if (orderCol?.column_name && /^[a-zA-Z0-9_]+$/.test(orderCol.column_name)) {
|
|
// ADR-151: consolidated into the shared helper (was an inline duplicate).
|
|
orderByClause = `ORDER BY ${buildOrderClause(orderCol.column_name, {
|
|
mode: 'guarded',
|
|
tieBreak: 'created_at DESC',
|
|
})}`;
|
|
}
|
|
|
|
const rows = await dbAll(`
|
|
SELECT
|
|
id,
|
|
base_id,
|
|
table_id,
|
|
data,
|
|
created_at,
|
|
updated_at
|
|
FROM table_rows
|
|
${whereClause}
|
|
${orderByClause}
|
|
LIMIT ? OFFSET ?
|
|
`, queryParams);
|
|
|
|
const countResult = await dbGet(`
|
|
SELECT COUNT(*) as total
|
|
FROM table_rows
|
|
${whereClause}
|
|
`, countParams);
|
|
|
|
const total = Number(countResult?.total) || 0;
|
|
const pages = Math.ceil(total / Number(limit));
|
|
|
|
let idToNameMap = {};
|
|
const columns = await dbAll(
|
|
'SELECT id, column_name FROM table_columns WHERE table_id = ?',
|
|
[tableId]
|
|
);
|
|
columns.forEach(col => {
|
|
idToNameMap[String(col.id)] = col.column_name;
|
|
});
|
|
|
|
const parsedRows = rows.map(row => {
|
|
const parsedData = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
|
|
let transformedData = { id: row.id };
|
|
if (Object.keys(idToNameMap).length > 0) {
|
|
for (const [key, value] of Object.entries(parsedData)) {
|
|
const colName = idToNameMap[key] || key;
|
|
transformedData[colName] = value;
|
|
}
|
|
} else {
|
|
Object.assign(transformedData, parsedData);
|
|
}
|
|
|
|
return {
|
|
...row,
|
|
data: transformedData
|
|
};
|
|
});
|
|
|
|
success(res, {
|
|
rows: parsedRows,
|
|
pagination: {
|
|
page: Number(page),
|
|
limit: Number(limit),
|
|
total,
|
|
pages
|
|
}
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /tables/:tableId/rows error');
|
|
error(res, 'ROWS_FETCH_FAILED', err.message, 500);
|
|
}
|
|
});
|
|
|
|
export default router;
|