diff --git a/backend/routes/databaseDiagnosticRoutes.js b/backend/routes/databaseDiagnosticRoutes.js new file mode 100644 index 0000000..82ee3ec --- /dev/null +++ b/backend/routes/databaseDiagnosticRoutes.js @@ -0,0 +1,15 @@ +const express = require('express'); +const { verifySuperAdmin } = require('../auth'); +const { buildDatabaseDiagnostic } = require('../services/databaseDiagnosticService'); + +const router = express.Router(); + +router.get('/admin/database-diagnostic', verifySuperAdmin, async (req, res, next) => { + try { + res.json(await buildDatabaseDiagnostic(req.user)); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index ef95918..020baf2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes'); const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); const catalogRoutes = require('./routes/catalogRoutes'); const supplyRoutes = require('./routes/supplyRoutes'); +const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes'); const createApp = () => { const app = express(); @@ -27,6 +28,7 @@ const createApp = () => { app.use('/api', cuttingSettingsRoutes); app.use('/api', catalogRoutes); app.use('/api', supplyRoutes); + app.use('/api', databaseDiagnosticRoutes); app.use('/api', analyticsRoutes); app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); diff --git a/backend/services/databaseDiagnosticService.js b/backend/services/databaseDiagnosticService.js new file mode 100644 index 0000000..dc03b55 --- /dev/null +++ b/backend/services/databaseDiagnosticService.js @@ -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 +}; diff --git a/src/dataService.ts b/src/dataService.ts index e02a2d5..856a7ff 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -161,6 +161,23 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise => { + const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' }); + const data = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.'); + } + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + URL.revokeObjectURL(url); +}; + export const fetchProductionOrders = async ( dateRange: DateRange, filters?: { search?: string }, diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index 7ae5e4f..0682314 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -28,7 +28,7 @@ import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductTypeBadge from '../components/ProductTypeBadge'; import { classifyCutFamily } from '../analytics/cutting'; -import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary } from '../dataService'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService'; import { parseProductName } from '../productParsing'; import { resolveProductType, type ProductTypeKey } from '../productClassification'; import { getPlanningStock } from '../planningStock'; @@ -192,9 +192,44 @@ const ModuleCard = ({ ); }; -const SuppliesHub = () => ( +const SuppliesHub = () => { + const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false); + const [diagnosticError, setDiagnosticError] = useState(''); + const canExportDiagnostic = isSuperAdmin(); + + const handleExportDiagnostic = async () => { + setIsExportingDiagnostic(true); + setDiagnosticError(''); + try { + await downloadDatabaseDiagnostic(); + } catch (error) { + setDiagnosticError(error instanceof Error ? error.message : 'Não foi possível exportar o diagnóstico do banco.'); + } finally { + setIsExportingDiagnostic(false); + } + }; + + return (
-
+
+
+ {canExportDiagnostic && ( + + )} +
+ {diagnosticError && ( +
+ {diagnosticError} +
+ )}
@@ -204,7 +239,8 @@ const SuppliesHub = () => (
-); + ); +}; const getRangeDays = (range: DateRange) => { const start = new Date(range.start);