refactor backend and persist stock campaign queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
This commit is contained in:
339
backend/index.js
339
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`);
|
||||
});
|
||||
Reference in New Issue
Block a user