Add top campaign clients endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
This commit is contained in:
@@ -1,14 +1,24 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { verifyToken } = require('../auth');
|
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||||
const {
|
const {
|
||||||
getCampaignPreview,
|
getCampaignPreview,
|
||||||
getCampaignQueueSummary,
|
getCampaignQueueSummary,
|
||||||
|
getTopClientsForCampaign,
|
||||||
processPendingStockCampaigns,
|
processPendingStockCampaigns,
|
||||||
retryCampaignItems
|
retryCampaignItems
|
||||||
} = require('../services/campaignService');
|
} = require('../services/campaignService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const verifyCampaignExportAccess = (req, res, next) => {
|
||||||
|
if (req.headers['x-api-key']) {
|
||||||
|
authenticateAPIKey(req, res, next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyToken(req, res, next);
|
||||||
|
};
|
||||||
|
|
||||||
router.get('/campaigns', verifyToken, async (req, res) => {
|
router.get('/campaigns', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getCampaignQueueSummary());
|
res.json(await getCampaignQueueSummary());
|
||||||
@@ -27,6 +37,15 @@ router.get('/campaigns/preview', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/campaigns/top-clients', verifyCampaignExportAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getTopClientsForCampaign(req.query || {}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching top campaign clients:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/campaigns/process', verifyToken, async (req, res) => {
|
router.post('/campaigns/process', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await processPendingStockCampaigns());
|
res.json(await processPendingStockCampaigns());
|
||||||
|
|||||||
@@ -10,8 +10,99 @@ const {
|
|||||||
} = require('./campaignFormatter');
|
} = require('./campaignFormatter');
|
||||||
|
|
||||||
const TOP_BUYERS_LIMIT = 100;
|
const TOP_BUYERS_LIMIT = 100;
|
||||||
|
const TOP_CLIENTS_DEFAULT_DAYS = 30;
|
||||||
|
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
|
||||||
|
const TOP_CLIENTS_MAX_LIMIT = 5000;
|
||||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||||
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
||||||
|
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
|
||||||
|
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
|
||||||
|
const CUSTOMER_IDENTITY_CTE = `
|
||||||
|
WITH customer_phone_by_name AS (
|
||||||
|
SELECT
|
||||||
|
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||||
|
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||||
|
)[1] as canonical_phone
|
||||||
|
FROM orders
|
||||||
|
WHERE NULLIF(cliente_fone, '') IS NOT NULL
|
||||||
|
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
|
||||||
|
GROUP BY normalized_customer_name
|
||||||
|
),
|
||||||
|
identity_orders AS (
|
||||||
|
SELECT
|
||||||
|
orders.*,
|
||||||
|
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(orders.cliente_fone, ''),
|
||||||
|
customer_phone_by_name.canonical_phone,
|
||||||
|
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
|
||||||
|
) as customer_key
|
||||||
|
FROM orders
|
||||||
|
LEFT JOIN customer_phone_by_name
|
||||||
|
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const normalizeDateParam = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, yearValue, monthValue, dayValue] = match;
|
||||||
|
const year = Number(yearValue);
|
||||||
|
const month = Number(monthValue);
|
||||||
|
const day = Number(dayValue);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== month - 1 ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${yearValue}-${monthValue}-${dayValue}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePositiveInteger = (value, defaultValue, maxValue) => {
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
|
||||||
|
return Math.min(parsed, maxValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(date);
|
||||||
|
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||||
|
|
||||||
|
return `${partMap.year}-${partMap.month}-${partMap.day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
|
||||||
|
const [year, month, day] = dateString.split('-').map(Number);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
date.setUTCDate(date.getUTCDate() - daysToSubtract);
|
||||||
|
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
|
||||||
|
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
|
||||||
|
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
|
||||||
|
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
days: normalizedDays,
|
||||||
|
start: normalizedStart,
|
||||||
|
end: normalizedEnd
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const enqueueStockCampaignItem = async (client, item) => {
|
const enqueueStockCampaignItem = async (client, item) => {
|
||||||
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
|
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
|
||||||
@@ -53,6 +144,48 @@ const getTopBuyersAllTime = async () => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
|
||||||
|
const range = getTopClientsDateRange({ days, start, end });
|
||||||
|
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
|
||||||
|
const result = await pool.query(`
|
||||||
|
${CUSTOMER_IDENTITY_CTE}
|
||||||
|
SELECT
|
||||||
|
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as nome,
|
||||||
|
customer_key as fone,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
|
||||||
|
COALESCE(SUM(quantidade), 0) as total_comprado,
|
||||||
|
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
|
||||||
|
MAX(data_pedido_date) as ultima_compra
|
||||||
|
FROM identity_orders
|
||||||
|
WHERE data_pedido_date >= $1::date
|
||||||
|
AND data_pedido_date <= $2::date
|
||||||
|
AND customer_key NOT LIKE 'name:%'
|
||||||
|
GROUP BY customer_key
|
||||||
|
ORDER BY total_gasto DESC
|
||||||
|
LIMIT $3;
|
||||||
|
`, [range.start, range.end, normalizedLimit]);
|
||||||
|
|
||||||
|
const customers = result.rows.map(row => ({
|
||||||
|
nome: row.nome,
|
||||||
|
fone: row.fone,
|
||||||
|
total_gasto: Number(row.total_gasto || 0),
|
||||||
|
total_comprado: Number(row.total_comprado || 0),
|
||||||
|
total_pedidos: Number(row.total_pedidos || 0),
|
||||||
|
ultima_compra: row.ultima_compra
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaign: 'top_clients',
|
||||||
|
days: range.days,
|
||||||
|
start: range.start,
|
||||||
|
end: range.end,
|
||||||
|
limit: normalizedLimit,
|
||||||
|
count: customers.length,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
customers
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const claimReadyCampaignItems = async () => {
|
const claimReadyCampaignItems = async () => {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
@@ -285,6 +418,7 @@ module.exports = {
|
|||||||
enqueueStockCampaignItem,
|
enqueueStockCampaignItem,
|
||||||
getCampaignPreview,
|
getCampaignPreview,
|
||||||
getCampaignQueueSummary,
|
getCampaignQueueSummary,
|
||||||
|
getTopClientsForCampaign,
|
||||||
retryCampaignItems,
|
retryCampaignItems,
|
||||||
processPendingStockCampaigns
|
processPendingStockCampaigns
|
||||||
};
|
};
|
||||||
|
|||||||
93
backend/test/campaignService.test.js
Normal file
93
backend/test/campaignService.test.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const withCampaignService = async (queryHandler, callback) => {
|
||||||
|
const dbPath = require.resolve('../db');
|
||||||
|
const servicePath = require.resolve('../services/campaignService');
|
||||||
|
const originalDbCache = require.cache[dbPath];
|
||||||
|
const originalServiceCache = require.cache[servicePath];
|
||||||
|
const queries = [];
|
||||||
|
|
||||||
|
delete require.cache[servicePath];
|
||||||
|
require.cache[dbPath] = {
|
||||||
|
id: dbPath,
|
||||||
|
filename: dbPath,
|
||||||
|
loaded: true,
|
||||||
|
exports: {
|
||||||
|
pool: {
|
||||||
|
query: async (sql, params = []) => {
|
||||||
|
queries.push({ sql, params });
|
||||||
|
return queryHandler(sql, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = require('../services/campaignService');
|
||||||
|
return await callback(service, queries);
|
||||||
|
} finally {
|
||||||
|
delete require.cache[servicePath];
|
||||||
|
if (originalServiceCache) {
|
||||||
|
require.cache[servicePath] = originalServiceCache;
|
||||||
|
}
|
||||||
|
if (originalDbCache) {
|
||||||
|
require.cache[dbPath] = originalDbCache;
|
||||||
|
} else {
|
||||||
|
delete require.cache[dbPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('getTopClientsForCampaign returns top clients for an explicit date range', async () => {
|
||||||
|
await withCampaignService(async () => ({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
nome: 'Cliente A',
|
||||||
|
fone: '5516999999901',
|
||||||
|
total_gasto: '1234.50',
|
||||||
|
total_comprado: '18',
|
||||||
|
total_pedidos: 4,
|
||||||
|
ultima_compra: '2026-07-27'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}), async ({ getTopClientsForCampaign }, queries) => {
|
||||||
|
const result = await getTopClientsForCampaign({
|
||||||
|
start: '2026-06-28',
|
||||||
|
end: '2026-07-27',
|
||||||
|
limit: '1000'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.start, '2026-06-28');
|
||||||
|
assert.equal(result.end, '2026-07-27');
|
||||||
|
assert.equal(result.limit, 1000);
|
||||||
|
assert.equal(result.count, 1);
|
||||||
|
assert.deepEqual(result.customers[0], {
|
||||||
|
nome: 'Cliente A',
|
||||||
|
fone: '5516999999901',
|
||||||
|
total_gasto: 1234.5,
|
||||||
|
total_comprado: 18,
|
||||||
|
total_pedidos: 4,
|
||||||
|
ultima_compra: '2026-07-27'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(queries.length, 1);
|
||||||
|
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
||||||
|
assert.match(queries[0].sql, /customer_key NOT LIKE 'name:%'/);
|
||||||
|
assert.match(queries[0].sql, /ORDER BY total_gasto DESC/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTopClientsForCampaign derives an inclusive 30 day range from the end date', async () => {
|
||||||
|
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
|
||||||
|
const result = await getTopClientsForCampaign({
|
||||||
|
days: '30',
|
||||||
|
end: '2026-07-27'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.start, '2026-06-28');
|
||||||
|
assert.equal(result.end, '2026-07-27');
|
||||||
|
assert.equal(result.limit, 1000);
|
||||||
|
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user