diff --git a/backend/routes/campaignRoutes.js b/backend/routes/campaignRoutes.js index 82047e8..3717006 100644 --- a/backend/routes/campaignRoutes.js +++ b/backend/routes/campaignRoutes.js @@ -1,14 +1,24 @@ const express = require('express'); -const { verifyToken } = require('../auth'); +const { authenticateAPIKey, verifyToken } = require('../auth'); const { getCampaignPreview, getCampaignQueueSummary, + getTopClientsForCampaign, processPendingStockCampaigns, retryCampaignItems } = require('../services/campaignService'); 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) => { try { 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) => { try { res.json(await processPendingStockCampaigns()); diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js index 3c9758c..4096bc9 100644 --- a/backend/services/campaignService.js +++ b/backend/services/campaignService.js @@ -10,8 +10,99 @@ const { } = require('./campaignFormatter'); 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 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) => { if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) { @@ -53,6 +144,48 @@ const getTopBuyersAllTime = async () => { 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 client = await pool.connect(); @@ -285,6 +418,7 @@ module.exports = { enqueueStockCampaignItem, getCampaignPreview, getCampaignQueueSummary, + getTopClientsForCampaign, retryCampaignItems, processPendingStockCampaigns }; diff --git a/backend/test/campaignService.test.js b/backend/test/campaignService.test.js new file mode 100644 index 0000000..6b412ff --- /dev/null +++ b/backend/test/campaignService.test.js @@ -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]); + }); +});