refactor backend and persist stock campaign queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s

This commit is contained in:
Cauê Faleiros
2026-05-27 15:00:23 -03:00
parent 6ba8219596
commit 8c2590c56a
25 changed files with 658 additions and 363 deletions

40
backend/auth.js Normal file
View File

@@ -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
};

11
backend/config.js Normal file
View File

@@ -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'
};

72
backend/db.js Normal file
View File

@@ -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
};

View File

@@ -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`);
});

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

25
backend/server.js Normal file
View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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
};