From 8c2590c56a9a7f26efdfd981ffe3a7308b0059b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 27 May 2026 15:00:23 -0300 Subject: [PATCH] refactor backend and persist stock campaign queue --- backend/auth.js | 40 ++++ backend/config.js | 11 + backend/db.js | 72 ++++++ backend/index.js | 339 ++-------------------------- backend/mappers/orderMapper.js | 35 +++ backend/mappers/stockMapper.js | 19 ++ backend/routes/authRoutes.js | 18 ++ backend/routes/dataRoutes.js | 26 +++ backend/routes/internalRoutes.js | 17 ++ backend/routes/stockRoutes.js | 26 +++ backend/server.js | 25 ++ backend/services/campaignService.js | 158 +++++++++++++ backend/services/ordersService.js | 47 ++++ backend/services/stockService.js | 56 +++++ src/components/DateRangePicker.tsx | 4 +- src/components/Layout.tsx | 8 +- src/dataService.ts | 10 +- src/main.tsx | 1 + src/pages/Clients.tsx | 22 +- src/pages/Dashboard.tsx | 18 +- src/pages/Login.tsx | 2 +- src/pages/ProductDetails.tsx | 11 +- src/pages/Products.tsx | 23 +- src/types.ts | 8 + vite.config.ts | 25 +- 25 files changed, 658 insertions(+), 363 deletions(-) create mode 100644 backend/auth.js create mode 100644 backend/config.js create mode 100644 backend/db.js create mode 100644 backend/mappers/orderMapper.js create mode 100644 backend/mappers/stockMapper.js create mode 100644 backend/routes/authRoutes.js create mode 100644 backend/routes/dataRoutes.js create mode 100644 backend/routes/internalRoutes.js create mode 100644 backend/routes/stockRoutes.js create mode 100644 backend/server.js create mode 100644 backend/services/campaignService.js create mode 100644 backend/services/ordersService.js create mode 100644 backend/services/stockService.js diff --git a/backend/auth.js b/backend/auth.js new file mode 100644 index 0000000..7e0a59e --- /dev/null +++ b/backend/auth.js @@ -0,0 +1,40 @@ +const jwt = require('jsonwebtoken'); +const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config'); + +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(); + }); +}; + +const authenticateAPIKey = (req, res, next) => { + const apiKey = req.headers['x-api-key']; + if (apiKey === API_KEY) { + next(); + return; + } + + res.status(401).json({ error: 'Unauthorized: Invalid API Key' }); +}; + +const login = (email, password) => { + if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) { + return null; + } + + return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' }); +}; + +module.exports = { + verifyToken, + authenticateAPIKey, + login +}; diff --git a/backend/config.js b/backend/config.js new file mode 100644 index 0000000..ab522b6 --- /dev/null +++ b/backend/config.js @@ -0,0 +1,11 @@ +require('dotenv').config(); + +module.exports = { + PORT: process.env.PORT || 3004, + API_KEY: process.env.API_KEY || 'nexstar_secret_key_123', + ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@admin.com', + ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', + JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123', + DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb', + N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp' +}; diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..b671089 --- /dev/null +++ b/backend/db.js @@ -0,0 +1,72 @@ +const { Pool } = require('pg'); +const { DATABASE_URL } = require('./config'); + +const pool = new Pool({ + connectionString: DATABASE_URL +}); + +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 + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS stock_campaign_queue ( + id SERIAL PRIMARY KEY, + base_product_name TEXT NOT NULL, + produto_id VARCHAR(100) NOT NULL, + nome TEXT NOT NULL, + saldo INTEGER DEFAULT 0, + delta_estoque INTEGER DEFAULT 0, + status VARCHAR(20) DEFAULT 'pending', + attempts INTEGER DEFAULT 0, + last_error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP + ); + `); + + await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`); + + console.log('Database initialized successfully.'); + } catch (err) { + console.error('Failed to initialize database:', err); + throw err; + } +}; + +module.exports = { + pool, + initDB +}; diff --git a/backend/index.js b/backend/index.js index e095296..bc5e0b0 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,332 +1,19 @@ -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 { createApp } = require('./server'); +const { initDB } = require('./db'); +const { PORT } = require('./config'); -const app = express(); -const PORT = process.env.PORT || 3004; -const API_KEY = process.env.API_KEY || "nexstar_secret_key_123"; +const start = async () => { + await initDB(); -// 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(); + const app = createApp(); + 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`); + console.log(`Scheduled campaign processor: POST http://localhost:${PORT}/api/internal/process-stock-campaigns`); }); }; -// 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' }); - } +start().catch((error) => { + console.error('Failed to start backend:', error); + process.exit(1); }); - -// 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`); -}); \ No newline at end of file diff --git a/backend/mappers/orderMapper.js b/backend/mappers/orderMapper.js new file mode 100644 index 0000000..43bc32d --- /dev/null +++ b/backend/mappers/orderMapper.js @@ -0,0 +1,35 @@ +const formatOrderRow = (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 +}); + +const normalizeOrderPayload = (item) => { + 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 || ''; + + return [ + item.Nome_Cliente || 'Unknown', + item.Data_Pedido || '', + parseFloat(item.Valor_Pedido) || 0, + item.ID_Produto || '', + item.Descricao_Produto || '', + parseInt(item.Quantidade, 10) || 0, + parseFloat(item.Valor_Unitario) || 0, + String(orderId), + String(fone) + ]; +}; + +module.exports = { + formatOrderRow, + normalizeOrderPayload +}; diff --git a/backend/mappers/stockMapper.js b/backend/mappers/stockMapper.js new file mode 100644 index 0000000..a3c233a --- /dev/null +++ b/backend/mappers/stockMapper.js @@ -0,0 +1,19 @@ +const getBaseProductName = (name) => String(name || 'Unknown').split(' TAMANHO')[0].trim(); + +const normalizeStockPayload = (item) => { + const produtoId = item.idProduto || item.ID_Produto || ''; + const nome = item.nome || item.Descricao_Produto || 'Unknown'; + + return { + produtoId: String(produtoId), + nome, + baseProductName: getBaseProductName(nome), + saldo: parseInt(item.saldo, 10) || 0, + deltaEstoque: parseInt(item.delta_estoque, 10) || 0 + }; +}; + +module.exports = { + getBaseProductName, + normalizeStockPayload +}; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js new file mode 100644 index 0000000..3076d92 --- /dev/null +++ b/backend/routes/authRoutes.js @@ -0,0 +1,18 @@ +const express = require('express'); +const { login } = require('../auth'); + +const router = express.Router(); + +router.post('/login', (req, res) => { + const { email, password } = req.body; + const token = login(email, password); + + if (!token) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + res.json({ token }); +}); + +module.exports = router; diff --git a/backend/routes/dataRoutes.js b/backend/routes/dataRoutes.js new file mode 100644 index 0000000..b9455d4 --- /dev/null +++ b/backend/routes/dataRoutes.js @@ -0,0 +1,26 @@ +const express = require('express'); +const { authenticateAPIKey, verifyToken } = require('../auth'); +const { listOrders, upsertOrders } = require('../services/ordersService'); + +const router = express.Router(); + +router.get('/data', verifyToken, async (req, res) => { + try { + res.json(await listOrders()); + } catch (error) { + console.error('Error fetching data:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.post('/data', authenticateAPIKey, async (req, res) => { + res.status(201).json({ message: 'Data received, processing in background' }); + + const payload = Array.isArray(req.body) ? req.body : [req.body]; + + upsertOrders(payload).catch((error) => { + console.error('Database insert error:', error); + }); +}); + +module.exports = router; diff --git a/backend/routes/internalRoutes.js b/backend/routes/internalRoutes.js new file mode 100644 index 0000000..ca4280c --- /dev/null +++ b/backend/routes/internalRoutes.js @@ -0,0 +1,17 @@ +const express = require('express'); +const { authenticateAPIKey } = require('../auth'); +const { processPendingStockCampaigns } = require('../services/campaignService'); + +const router = express.Router(); + +router.post('/process-stock-campaigns', authenticateAPIKey, async (req, res) => { + try { + const summary = await processPendingStockCampaigns(); + res.json(summary); + } catch (error) { + console.error('Error processing stock campaigns:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/stockRoutes.js b/backend/routes/stockRoutes.js new file mode 100644 index 0000000..1950951 --- /dev/null +++ b/backend/routes/stockRoutes.js @@ -0,0 +1,26 @@ +const express = require('express'); +const { authenticateAPIKey, verifyToken } = require('../auth'); +const { listStock, upsertStockItems } = require('../services/stockService'); + +const router = express.Router(); + +router.get('/stock', verifyToken, async (req, res) => { + try { + res.json(await listStock()); + } catch (error) { + console.error('Error fetching stock:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.post('/stock', authenticateAPIKey, async (req, res) => { + res.status(201).json({ message: 'Stock data received, processing in background' }); + + const payload = Array.isArray(req.body) ? req.body : [req.body]; + + upsertStockItems(payload).catch((error) => { + console.error('Database stock insert error:', error); + }); +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..258d7fd --- /dev/null +++ b/backend/server.js @@ -0,0 +1,25 @@ +const express = require('express'); +const cors = require('cors'); +const bodyParser = require('body-parser'); +const authRoutes = require('./routes/authRoutes'); +const dataRoutes = require('./routes/dataRoutes'); +const stockRoutes = require('./routes/stockRoutes'); +const internalRoutes = require('./routes/internalRoutes'); + +const createApp = () => { + const app = express(); + + app.use(cors()); + app.use(bodyParser.json()); + + app.use('/api', authRoutes); + app.use('/api', dataRoutes); + app.use('/api', stockRoutes); + app.use('/api/internal', internalRoutes); + + return app; +}; + +module.exports = { + createApp +}; diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js new file mode 100644 index 0000000..330b00b --- /dev/null +++ b/backend/services/campaignService.js @@ -0,0 +1,158 @@ +const { pool } = require('../db'); +const { N8N_WHATSAPP_TRIGGER_URL } = require('../config'); + +const TOP_BUYERS_LIMIT = 100; +const MAX_CAMPAIGN_ATTEMPTS = 3; + +const enqueueStockCampaignItem = async (client, item) => { + const query = ` + INSERT INTO stock_campaign_queue ( + base_product_name, produto_id, nome, saldo, delta_estoque + ) VALUES ($1, $2, $3, $4, $5) + `; + + await client.query(query, [ + item.baseProductName, + item.produtoId, + item.nome, + item.saldo, + item.deltaEstoque + ]); +}; + +const getTopBuyersAllTime = async () => { + const result = await pool.query(` + SELECT + MAX(cliente_nome) as nome, + cliente_fone as fone, + SUM(quantidade * valor_unitario) as total_gasto, + SUM(quantidade) as total_comprado + FROM orders + WHERE cliente_fone IS NOT NULL + AND cliente_fone != '' + GROUP BY cliente_fone + ORDER BY total_gasto DESC + LIMIT $1; + `, [TOP_BUYERS_LIMIT]); + + return result.rows; +}; + +const claimPendingCampaignItems = async () => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + const result = await client.query(` + UPDATE stock_campaign_queue + SET status = 'processing', + attempts = attempts + 1, + updated_at = CURRENT_TIMESTAMP, + last_error = NULL + WHERE id IN ( + SELECT id + FROM stock_campaign_queue + WHERE status IN ('pending', 'failed') + AND attempts < $1 + ORDER BY created_at ASC + FOR UPDATE SKIP LOCKED + ) + RETURNING *; + `, [MAX_CAMPAIGN_ATTEMPTS]); + + await client.query('COMMIT'); + return result.rows; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => { + if (!ids.length) return; + + await pool.query(` + UPDATE stock_campaign_queue + SET status = $1, + last_error = $2, + updated_at = CURRENT_TIMESTAMP, + sent_at = CASE WHEN $1 = 'sent' THEN CURRENT_TIMESTAMP ELSE sent_at END + WHERE id = ANY($3::int[]); + `, [status, errorMessage, ids]); +}; + +const sendWhatsappCampaign = async (baseProductName, items, customers) => { + const totalDelta = items.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0); + + const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseProduct: baseProductName, + total_delta: totalDelta, + sizes: items.map(item => ({ + id: item.produto_id, + nome: item.nome, + delta: item.delta_estoque, + saldo: item.saldo + })), + customers + }) + }); + + if (!response.ok) { + throw new Error(`WhatsApp webhook returned status ${response.status}`); + } +}; + +const processPendingStockCampaigns = async () => { + const rows = await claimPendingCampaignItems(); + const summary = { + claimed: rows.length, + sentGroups: 0, + skippedGroups: 0, + failedGroups: 0 + }; + + if (!rows.length) { + return summary; + } + + const customers = await getTopBuyersAllTime(); + const groups = rows.reduce((acc, row) => { + if (!acc[row.base_product_name]) acc[row.base_product_name] = []; + acc[row.base_product_name].push(row); + return acc; + }, {}); + + for (const [baseProductName, items] of Object.entries(groups)) { + const ids = items.map(item => item.id); + + if (!customers.length) { + await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.'); + summary.skippedGroups += 1; + continue; + } + + try { + await sendWhatsappCampaign(baseProductName, items, customers); + await updateCampaignItemsStatus(ids, 'sent'); + summary.sentGroups += 1; + console.log(`[Campaign Queue] Sent ${baseProductName} campaign to ${customers.length} all-time top buyers.`); + } catch (error) { + await updateCampaignItemsStatus(ids, 'failed', error.message); + summary.failedGroups += 1; + console.error(`[Campaign Queue] Failed to send ${baseProductName} campaign:`, error); + } + } + + return summary; +}; + +module.exports = { + enqueueStockCampaignItem, + processPendingStockCampaigns +}; diff --git a/backend/services/ordersService.js b/backend/services/ordersService.js new file mode 100644 index 0000000..290ba56 --- /dev/null +++ b/backend/services/ordersService.js @@ -0,0 +1,47 @@ +const { pool } = require('../db'); +const { formatOrderRow, normalizeOrderPayload } = require('../mappers/orderMapper'); + +const listOrders = async () => { + const result = await pool.query('SELECT * FROM orders ORDER BY id DESC'); + return result.rows.map(formatOrderRow); +}; + +const upsertOrders = async (payload) => { + 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) { + await client.query(insertQuery, normalizeOrderPayload(item)); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +module.exports = { + listOrders, + upsertOrders +}; diff --git a/backend/services/stockService.js b/backend/services/stockService.js new file mode 100644 index 0000000..0d5b2e5 --- /dev/null +++ b/backend/services/stockService.js @@ -0,0 +1,56 @@ +const { pool } = require('../db'); +const { normalizeStockPayload } = require('../mappers/stockMapper'); +const { enqueueStockCampaignItem } = require('./campaignService'); + +const CAMPAIGN_DELTA_THRESHOLD = 100; + +const listStock = async () => { + const result = await pool.query('SELECT * FROM stock'); + return result.rows; +}; + +const upsertStockItems = async (payload) => { + 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 rawItem of payload) { + const item = normalizeStockPayload(rawItem); + if (!item.produtoId) continue; + + await client.query(insertQuery, [ + item.produtoId, + item.nome, + item.saldo, + item.deltaEstoque + ]); + + if (item.deltaEstoque >= CAMPAIGN_DELTA_THRESHOLD) { + await enqueueStockCampaignItem(client, item); + } + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +module.exports = { + listStock, + upsertStockItems +}; diff --git a/src/components/DateRangePicker.tsx b/src/components/DateRangePicker.tsx index 2b37444..7538905 100644 --- a/src/components/DateRangePicker.tsx +++ b/src/components/DateRangePicker.tsx @@ -76,7 +76,7 @@ const DateRangePicker: React.FC = ({ dateRange, onChange, } else { ref.current.focus(); } - } catch (e) { + } catch { ref.current.focus(); } } @@ -183,4 +183,4 @@ const DateRangePicker: React.FC = ({ dateRange, onChange, ); }; -export default DateRangePicker; \ No newline at end of file +export default DateRangePicker; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index c15009a..9a66664 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react'; -import type { DateRange, OrderData } from '../types'; +import type { DateRange, OrderData, StockData } from '../types'; import { fetchData, fetchStock, logout } from '../dataService'; const Layout = () => { @@ -25,7 +25,7 @@ const Layout = () => { }); const [ordersData, setOrdersData] = useState([]); - const [stockData, setStockData] = useState([]); + const [stockData, setStockData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [refreshInterval, setRefreshInterval] = useState(() => { const saved = localStorage.getItem('nexstar_refresh_interval'); @@ -41,7 +41,9 @@ const Layout = () => { }; useEffect(() => { - loadData(true); + // The dashboard has to fetch its initial server state after mount. + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadData(true); }, []); useEffect(() => { diff --git a/src/dataService.ts b/src/dataService.ts index 23f8edb..d63f59c 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { OrderData } from './types'; +import type { OrderData, StockData } from './types'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -33,7 +33,7 @@ export const isAuthenticated = (): boolean => { return !!localStorage.getItem('auth_token'); }; -export const fetchStock = async (): Promise => { +export const fetchStock = async (): Promise => { try { const token = localStorage.getItem('auth_token'); const response = await fetch(`${API_URL}/stock`, { @@ -47,7 +47,7 @@ export const fetchStock = async (): Promise => { } if (!response.ok) return []; return await response.json(); - } catch (error) { + } catch { return []; } }; @@ -89,7 +89,7 @@ export const parseOrderDate = (dateStr: string): Date => { return isNaN(fallback.getTime()) ? new Date(0) : fallback; }; -export const exportToCSV = (data: any[], filename: string) => { +export const exportToCSV = (data: Record[], filename: string) => { if (!data || !data.length) return; const headers = Object.keys(data[0]); @@ -102,7 +102,7 @@ export const exportToCSV = (data: any[], filename: string) => { for (const row of data) { const values = headers.map(header => { const val = row[header]; - const escaped = ('' + val).replace(/"/g, '\\"'); + const escaped = String(val ?? '').replace(/"/g, '""'); return `"${escaped}"`; }); csvRows.push(values.join(',')); diff --git a/src/main.tsx b/src/main.tsx index 8d5c921..ab0d25a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import '@vitejs/plugin-react/preamble' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { HashRouter } from 'react-router-dom' diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 0ca9be6..4a99446 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; import type { OrderData, DateRange } from '../types'; @@ -20,11 +20,6 @@ const Clients = () => { const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); - // Reset to first page when search, sort, or date changes - useEffect(() => { - setCurrentPage(1); - }, [searchTerm, sortBy, dateRange]); - const clientsData = useMemo(() => { const orders = ordersData.filter(order => { const orderDate = parseOrderDate(order.Data_Pedido); @@ -101,14 +96,20 @@ const Clients = () => {
{ + setDateRange(range); + setCurrentPage(1); + }} />