Add database diagnostic export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
This commit is contained in:
169
backend/services/databaseDiagnosticService.js
Normal file
169
backend/services/databaseDiagnosticService.js
Normal file
@@ -0,0 +1,169 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const SAMPLE_LIMIT = 50;
|
||||
|
||||
const sampleSpecs = {
|
||||
catalog_categories: {
|
||||
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
catalog_products: {
|
||||
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
consumption_references: {
|
||||
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
cutting_family_rules: {
|
||||
columns: ['family_key', 'units_per_roll', 'updated_at'],
|
||||
orderBy: ['family_key']
|
||||
},
|
||||
cutting_product_overrides: {
|
||||
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
|
||||
orderBy: ['updated_at', 'product_id']
|
||||
},
|
||||
orders: {
|
||||
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
|
||||
orderBy: ['data_pedido_date', 'created_at', 'id']
|
||||
},
|
||||
production_order_markers: {
|
||||
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
production_orders: {
|
||||
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
stock: {
|
||||
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
|
||||
orderBy: ['updated_at', 'produto_id']
|
||||
},
|
||||
stock_campaign_queue: {
|
||||
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_fabric_plans: {
|
||||
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_movements: {
|
||||
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
supply_receipts: {
|
||||
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_stock_lots: {
|
||||
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
}
|
||||
};
|
||||
|
||||
const quoteIdentifier = (identifier) => {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
|
||||
throw new Error(`Unsafe database identifier: ${identifier}`);
|
||||
}
|
||||
|
||||
return `"${identifier}"`;
|
||||
};
|
||||
|
||||
const listPublicTables = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
return result.rows.map(row => row.table_name);
|
||||
};
|
||||
|
||||
const listPublicColumns = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name, ordinal_position
|
||||
`);
|
||||
|
||||
return result.rows.reduce((columnsByTable, row) => {
|
||||
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
|
||||
columnsByTable[row.table_name].push(row.column_name);
|
||||
return columnsByTable;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const countTableRows = async (tableName) => {
|
||||
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
|
||||
return result.rows[0]?.count || 0;
|
||||
};
|
||||
|
||||
const buildOrderClause = (spec, availableColumns) => {
|
||||
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
|
||||
if (!orderColumns.length) return '';
|
||||
|
||||
const clauses = orderColumns.map(column => {
|
||||
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
|
||||
return `${quoteIdentifier(column)} ${direction}`;
|
||||
});
|
||||
|
||||
return ` ORDER BY ${clauses.join(', ')}`;
|
||||
};
|
||||
|
||||
const sampleTableRows = async (tableName, availableColumns) => {
|
||||
const spec = sampleSpecs[tableName];
|
||||
if (!spec) return null;
|
||||
|
||||
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
|
||||
if (!selectedColumns.length) return null;
|
||||
|
||||
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
|
||||
const orderClause = buildOrderClause(spec, availableColumns);
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
|
||||
[SAMPLE_LIMIT]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const buildDatabaseDiagnostic = async (user) => {
|
||||
const [tables, columnsByTable] = await Promise.all([
|
||||
listPublicTables(),
|
||||
listPublicColumns()
|
||||
]);
|
||||
|
||||
const counts = {};
|
||||
const samples = {};
|
||||
|
||||
for (const tableName of tables) {
|
||||
counts[tableName] = await countTableRows(tableName);
|
||||
|
||||
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
|
||||
if (sampleRows) samples[tableName] = sampleRows;
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: {
|
||||
role: user?.role || null,
|
||||
userId: user?.userId || null
|
||||
},
|
||||
sampleLimit: SAMPLE_LIMIT,
|
||||
privacy: {
|
||||
countsIncludeAllPublicTables: true,
|
||||
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
|
||||
ordersSampleExcludesCustomerNameAndPhone: true,
|
||||
productionOrdersSampleExcludesTinyPayload: true
|
||||
},
|
||||
counts,
|
||||
samples
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildDatabaseDiagnostic
|
||||
};
|
||||
Reference in New Issue
Block a user