const express = require('express'); const cors = require('cors'); const bodyParser = require('body-parser'); const { Pool } = require('pg'); const jwt = require('jsonwebtoken'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 3004; const API_KEY = process.env.API_KEY || "nexstar_secret_key_123"; // Admin Credentials const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123'; app.use(cors()); app.use(bodyParser.json()); // PostgreSQL Connection Pool const pool = new Pool({ connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb', }); // Initialize Database Table const initDB = async () => { try { await pool.query(` CREATE TABLE IF NOT EXISTS orders ( id SERIAL PRIMARY KEY, cliente_nome VARCHAR(255), data_pedido VARCHAR(50), valor_pedido NUMERIC(10, 2), produto_id VARCHAR(100), produto_descricao TEXT, quantidade INTEGER, valor_unitario NUMERIC(10, 5), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `); await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {}); await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_fone VARCHAR(50);`).catch(() => {}); await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS unique_order_product ON orders (pedido_id, produto_id);`).catch(err => { console.error("Notice: Could not create unique index (might already exist or there are duplicates):", err.message); }); await pool.query(` CREATE TABLE IF NOT EXISTS stock ( produto_id VARCHAR(100) PRIMARY KEY, nome TEXT, saldo INTEGER DEFAULT 0, delta_estoque INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `); console.log("Database initialized successfully."); } catch (err) { console.error("Failed to initialize database:", err); } }; initDB(); // Middleware for Frontend Authentication const verifyToken = (req, res, next) => { const authHeader = req.headers['authorization']; if (!authHeader) return res.status(403).json({ error: 'No token provided' }); const token = authHeader.split(' ')[1]; if (!token) return res.status(403).json({ error: 'Malformed token' }); jwt.verify(token, JWT_SECRET, (err, decoded) => { if (err) return res.status(401).json({ error: 'Unauthorized' }); req.user = decoded; next(); }); }; // Login Endpoint app.post('/api/login', (req, res) => { const { email, password } = req.body; if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) { const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' }); res.json({ token }); } else { res.status(401).json({ error: 'Invalid credentials' }); } }); // Helper to format rows to match the old JSON structure for the frontend const formatRow = (row) => ({ Nome_Cliente: row.cliente_nome, Data_Pedido: row.data_pedido, Valor_Pedido: parseFloat(row.valor_pedido), ID_Produto: row.produto_id, Descricao_Produto: row.produto_descricao, Quantidade: row.quantidade, Valor_Unitario: parseFloat(row.valor_unitario), Recebido_Em: row.created_at, ID_Pedido: row.pedido_id, Fone_Cliente: row.cliente_fone }); // GET data (for the frontend) app.get('/api/data', verifyToken, async (req, res) => { try { const result = await pool.query('SELECT * FROM orders ORDER BY id DESC'); const formattedData = result.rows.map(formatRow); res.json(formattedData); } catch (error) { console.error("Error fetching data:", error); res.status(500).json({ error: 'Internal Server Error' }); } }); // GET stock (for the frontend) app.get('/api/stock', verifyToken, async (req, res) => { try { const result = await pool.query('SELECT * FROM stock'); res.json(result.rows); } catch (error) { console.error("Error fetching stock:", error); res.status(500).json({ error: 'Internal Server Error' }); } }); // POST data (for n8n) - Protected by API_KEY internally or via middleware if needed // Leaving it as it was, checking API_KEY manually? Wait, the previous version didn't actually use 'authenticate' middleware on the POST! // Let's add the authenticate middleware to the POST endpoint. const authenticateAPIKey = (req, res, next) => { const apiKey = req.headers['x-api-key']; if (apiKey === API_KEY) { next(); } else { res.status(401).json({ error: 'Unauthorized: Invalid API Key' }); } }; app.post('/api/data', authenticateAPIKey, async (req, res) => { // Respond IMMEDIATELY to prevent slowing down n8n / WhatsApp flows res.status(201).json({ message: 'Data received, processing in background' }); const newData = req.body; const payload = Array.isArray(newData) ? newData : [newData]; // Process asynchronously (async () => { const client = await pool.connect(); try { await client.query('BEGIN'); const insertQuery = ` INSERT INTO orders ( cliente_nome, data_pedido, valor_pedido, produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (pedido_id, produto_id) DO UPDATE SET cliente_nome = EXCLUDED.cliente_nome, data_pedido = EXCLUDED.data_pedido, valor_pedido = EXCLUDED.valor_pedido, produto_descricao = EXCLUDED.produto_descricao, quantidade = EXCLUDED.quantidade, valor_unitario = EXCLUDED.valor_unitario, cliente_fone = EXCLUDED.cliente_fone, created_at = CURRENT_TIMESTAMP `; for (const item of payload) { // Handle potential missing fields gracefully // If there is no explicit ID, create a composite ID using Name + Date + Value to prevent squashing historical data const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`; const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId; const fone = item.Fone_Cliente || item.fone || item.celular || ''; const values = [ item.Nome_Cliente || 'Unknown', item.Data_Pedido || '', parseFloat(item.Valor_Pedido) || 0, item.ID_Produto || '', item.Descricao_Produto || '', parseInt(item.Quantidade) || 0, parseFloat(item.Valor_Unitario) || 0, String(orderId), String(fone) ]; await client.query(insertQuery, values); } await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); console.error("Database insert error:", error); } finally { client.release(); } })(); }); // In-memory Waiting Room for WhatsApp Marketing const waitingRoom = {}; const N8N_WHATSAPP_TRIGGER_URL = process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'; // POST stock (for n8n) app.post('/api/stock', authenticateAPIKey, async (req, res) => { res.status(201).json({ message: 'Stock data received, processing in background' }); const newData = req.body; const payload = Array.isArray(newData) ? newData : [newData]; (async () => { const client = await pool.connect(); try { await client.query('BEGIN'); const insertQuery = ` INSERT INTO stock (produto_id, nome, saldo, delta_estoque) VALUES ($1, $2, $3, $4) ON CONFLICT (produto_id) DO UPDATE SET nome = EXCLUDED.nome, saldo = EXCLUDED.saldo, delta_estoque = EXCLUDED.delta_estoque, updated_at = CURRENT_TIMESTAMP `; for (const item of payload) { const idProduto = item.idProduto || item.ID_Produto || ''; if (!idProduto) continue; const delta = parseInt(item.delta_estoque) || 0; const nomeStr = item.nome || item.Descricao_Produto || 'Unknown'; const values = [ String(idProduto), nomeStr, parseInt(item.saldo) || 0, delta ]; await client.query(insertQuery, values); // Waiting Room / Debounce Logic if (delta >= 100) { const baseProductName = nomeStr.split(' TAMANHO')[0].trim(); if (!waitingRoom[baseProductName]) { console.log(`[Waiting Room] Nova entrada: ${baseProductName}. Iniciando timer de 30m...`); waitingRoom[baseProductName] = { total_delta: 0, items: [], timeout: null }; } else { console.log(`[Waiting Room] Atualização: ${baseProductName}. Timer reiniciado (30m).`); } waitingRoom[baseProductName].total_delta += delta; waitingRoom[baseProductName].items.push({ id: String(idProduto), nome: nomeStr, delta: delta, saldo: parseInt(item.saldo) || 0 }); if (waitingRoom[baseProductName].timeout) { clearTimeout(waitingRoom[baseProductName].timeout); } waitingRoom[baseProductName].timeout = setTimeout(async () => { try { const aggData = waitingRoom[baseProductName]; delete waitingRoom[baseProductName]; // Clear from waiting room console.log(`[Waiting Room] Timer finalizado para ${baseProductName}. Buscando top buyers...`); // Find Top 100 buyers for this Base Product const topBuyersQuery = ` SELECT MAX(cliente_nome) as nome, cliente_fone as fone, SUM(quantidade) as total_comprado FROM orders WHERE produto_descricao LIKE $1 AND cliente_fone IS NOT NULL AND cliente_fone != '' GROUP BY cliente_fone ORDER BY total_comprado DESC LIMIT 100; `; const topBuyersResult = await pool.query(topBuyersQuery, [`${baseProductName}%`]); // Only trigger if we actually have buyers if (topBuyersResult.rows.length > 0) { console.log(`[Waiting Room] Disparando webhook do WhatsApp para ${topBuyersResult.rows.length} clientes (${baseProductName})...`); fetch(N8N_WHATSAPP_TRIGGER_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseProduct: baseProductName, total_delta: aggData.total_delta, sizes: aggData.items, customers: topBuyersResult.rows }) }).then(res => { if(res.ok) console.log(`[Waiting Room] Sucesso! Webhook disparado.`); else console.log(`[Waiting Room] Aviso: Webhook retornou status ${res.status}`); }).catch(err => console.error("Failed to trigger WhatsApp webhook:", err)); } else { console.log(`[Waiting Room] Nenhum cliente encontrado com telefone válido para ${baseProductName}. Webhook cancelado.`); } } catch (err) { console.error("Error in Waiting Room timeout:", err); } }, 1800000); // 30 minutes } } await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); console.error("Database stock insert error:", error); } finally { client.release(); } })(); }); app.listen(PORT, '0.0.0.0', () => { console.log(`Nexstar Backend running at http://localhost:${PORT}`); console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`); });