Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s

This commit is contained in:
Cauê Faleiros
2026-06-22 11:00:42 -03:00
parent 3c74fa7210
commit 0874238fc6
12 changed files with 548 additions and 43 deletions

View File

@@ -97,6 +97,15 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS client_identity_tokens (
customer_key TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`);
await pool.query(`
ALTER TABLE app_users
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -140,6 +149,13 @@ const initDB = async () => {
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_customer_key_date
ON orders (
(COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))),
data_pedido_date
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`);

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { verifyToken } = require('../auth');
const {
getClientAnalytics,
getClientDetailsAnalytics,
getDashboardAnalytics,
getProductAnalytics,
getRfmAnalytics
@@ -41,6 +42,21 @@ router.get('/analytics/clients', verifyToken, async (req, res) => {
}
});
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
try {
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
if (!details) {
res.status(404).json({ error: 'Client not found' });
return;
}
res.json(details);
} catch (error) {
console.error('Error fetching client details analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/rfm', verifyToken, async (req, res) => {
try {
res.json(await getRfmAnalytics(getRange(req.query)));

View File

@@ -1,3 +1,4 @@
const crypto = require('node:crypto');
const { pool } = require('../db');
const RFM_QUERY_TIMEOUT_MS = 15000;
@@ -5,6 +6,7 @@ const RECENT_MAX_DAYS = 60;
const COOLING_MAX_DAYS = 180;
const LOST_MIN_DAYS = 366;
const FREQUENCY_MEDIUM_MAX_ORDERS = 4;
const CLIENT_TOKEN_VERSION = 'v1';
const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
const PRODUCT_NAME_SQL = `
CASE
@@ -14,6 +16,92 @@ const PRODUCT_NAME_SQL = `
`;
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
const getClientTokenSecret = () => (
process.env.CLIENT_TOKEN_SECRET ||
process.env.JWT_SECRET ||
process.env.API_KEY ||
'nexstar-client-token-development-secret'
);
let clientTokenSecretCache = null;
const getClientTokenHashSecret = () => {
const secret = getClientTokenSecret();
if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret;
clientTokenSecretCache = {
secret,
hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url')
};
return clientTokenSecretCache.hashSecret;
};
const createClientToken = (customerKey) => {
if (!customerKey) return '';
const normalizedCustomerKey = String(customerKey);
const digest = crypto
.createHash('sha256')
.update(getClientTokenHashSecret())
.update(':')
.update(normalizedCustomerKey)
.digest('base64url');
return `${CLIENT_TOKEN_VERSION}.${digest}`;
};
const isClientToken = (clientToken) => {
const parts = String(clientToken || '').split('.');
return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || '');
};
const persistClientTokenMappings = async (clients, queryable = pool) => {
const mappingsByCustomerKey = new Map();
clients.forEach(client => {
const customerKey = client.customerKey || client.customer_key;
const clientToken = client.clientToken || createClientToken(customerKey);
if (customerKey && clientToken) {
mappingsByCustomerKey.set(customerKey, clientToken);
}
});
const mappings = [...mappingsByCustomerKey.entries()];
const chunkSize = 5000;
for (let index = 0; index < mappings.length; index += chunkSize) {
const chunk = mappings.slice(index, index + chunkSize);
const params = [];
const values = chunk.map(([customerKey, clientToken], chunkIndex) => {
params.push(customerKey, clientToken);
const offset = chunkIndex * 2;
return `($${offset + 1}, $${offset + 2})`;
});
await queryable.query(`
INSERT INTO client_identity_tokens (customer_key, token)
VALUES ${values.join(', ')}
ON CONFLICT (customer_key)
DO UPDATE SET
token = EXCLUDED.token,
updated_at = NOW()
WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token;
`, params);
}
};
const resolveClientToken = async (clientToken) => {
if (!isClientToken(clientToken)) return null;
const result = await pool.query(`
SELECT customer_key
FROM client_identity_tokens
WHERE token = $1
LIMIT 1;
`, [clientToken]);
return result.rows[0]?.customer_key || null;
};
const normalizeDateParam = (value) => {
if (!value) return null;
@@ -356,8 +444,9 @@ const getClientAnalytics = async (range = {}) => {
ORDER BY total_spent DESC;
`, params);
return result.rows.map(row => ({
const clients = result.rows.map(row => ({
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
quantityPurchased: toNumber(row.quantity_purchased),
@@ -365,6 +454,140 @@ const getClientAnalytics = async (range = {}) => {
orderCount: toNumber(row.order_count),
lastPurchaseDate: row.last_purchase_date
}));
await persistClientTokenMappings(clients);
return clients;
};
const getOrderGroupKey = (row) => (
row.pedido_id ||
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
);
const getClientDetailsAnalytics = async (clientToken, range = {}) => {
const customerKey = await resolveClientToken(clientToken);
if (!customerKey) return null;
const normalizedStart = normalizeDateParam(range.start);
const normalizedEnd = normalizeDateParam(range.end);
const periodParams = [customerKey];
const periodFilters = [
`${CUSTOMER_KEY_SQL} = $1`,
'data_pedido_date IS NOT NULL'
];
if (normalizedStart) {
periodParams.push(normalizedStart);
periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`);
}
if (normalizedEnd) {
periodParams.push(normalizedEnd);
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`);
}
const [summaryResult, periodResult] = await Promise.all([
pool.query(`
SELECT
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count
FROM orders
WHERE ${CUSTOMER_KEY_SQL} = $1
AND data_pedido_date IS NOT NULL;
`, [customerKey]),
pool.query(`
SELECT
cliente_nome,
cliente_fone,
data_pedido,
data_pedido_date,
valor_pedido,
produto_id,
produto_descricao,
quantidade,
valor_unitario,
pedido_id
FROM orders
WHERE ${periodFilters.join(' AND ')}
ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC;
`, periodParams)
]);
const summary = summaryResult.rows[0] || {};
const allTimeOrderCount = toNumber(summary.all_time_order_count);
if (!allTimeOrderCount) return null;
const groupedOrdersByKey = new Map();
const spentByDate = new Map();
let periodSpent = 0;
let periodItems = 0;
periodResult.rows.forEach(row => {
const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario);
const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || '';
const dateLabel = row.data_pedido || dateKey;
const groupKey = getOrderGroupKey(row);
periodSpent += itemRevenue;
periodItems += toNumber(row.quantidade);
if (dateLabel) {
const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 };
currentDateSpend.value += itemRevenue;
spentByDate.set(dateLabel, currentDateSpend);
}
if (!groupedOrdersByKey.has(groupKey)) {
groupedOrdersByKey.set(groupKey, {
date: dateLabel,
sortDate: dateKey,
orderId: row.pedido_id || groupKey,
orderTotal: 0,
items: []
});
}
const group = groupedOrdersByKey.get(groupKey);
group.orderTotal += itemRevenue;
group.items.push({
Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido',
Data_Pedido: dateLabel,
Valor_Pedido: toNumber(row.valor_pedido),
ID_Produto: row.produto_id || '',
Descricao_Produto: row.produto_descricao || 'Unknown',
Quantidade: toNumber(row.quantidade),
Valor_Unitario: toNumber(row.valor_unitario),
ID_Pedido: row.pedido_id || '',
Fone_Cliente: row.cliente_fone || ''
});
});
const groupedOrders = [...groupedOrdersByKey.values()]
.sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate)))
.map(({ sortDate, ...group }) => group);
const chartData = [...spentByDate.values()]
.sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate)))
.map(({ sortDate, ...entry }) => entry);
const periodOrderCount = groupedOrders.length;
return {
range: {
start: normalizedStart,
end: normalizedEnd
},
clientToken,
clientName: summary.name || 'Cliente Desconhecido',
clientPhone: summary.phone || '',
hasClient: true,
allTimeOrderCount,
periodSpent,
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
periodOrderCount,
periodItems,
chartData,
groupedOrders
};
};
const getRfmAnalytics = async (range = {}) => {
@@ -379,6 +602,7 @@ const getRfmAnalytics = async (range = {}) => {
const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10);
const clients = buildRfmClients(clientRows.map(row => ({
customerKey: row.customerKey,
clientToken: row.clientToken || createClientToken(row.customerKey),
name: row.name,
phone: row.phone || '',
monetary: row.totalSpent,
@@ -492,6 +716,7 @@ const getRfmAnalytics = async (range = {}) => {
const historyClients = buildRfmClients(historyRows.map(row => ({
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
@@ -509,6 +734,7 @@ const getRfmAnalytics = async (range = {}) => {
if (!taggedClient) {
const [fallbackClient] = buildRfmClients([{
customerKey: row.customer_key,
clientToken: createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
@@ -523,6 +749,7 @@ const getRfmAnalytics = async (range = {}) => {
return {
...taggedClient,
customerKey: row.customer_key,
clientToken: taggedClient.clientToken || createClientToken(row.customer_key),
name: row.name,
phone: row.phone || '',
monetary: toNumber(row.monetary),
@@ -536,6 +763,7 @@ const getRfmAnalytics = async (range = {}) => {
return b.monetary - a.monetary;
});
await persistClientTokenMappings(clients, client);
await client.query('COMMIT');
return {
@@ -562,7 +790,10 @@ module.exports = {
buildDateFilter,
buildRfmClients,
buildRfmSegments,
createClientToken,
isClientToken,
getFrequencyScore,
getClientDetailsAnalytics,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,

View File

@@ -6,6 +6,10 @@ const {
buildRfmSegments,
buildDateFilter,
getFrequencyScore,
createClientToken,
isClientToken,
getClientAnalytics,
getClientDetailsAnalytics,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,
@@ -91,6 +95,21 @@ test('getPreviousDate returns the calendar day before an ISO date', () => {
assert.equal(getPreviousDate('invalid'), null);
});
test('client tokens are opaque and stable for customer keys', () => {
const phoneKey = '(16) 99103-6131';
const nameKey = 'name:Cliente Sem Fone';
const phoneToken = createClientToken(phoneKey);
const nameToken = createClientToken(nameKey);
assert.ok(isClientToken(phoneToken));
assert.ok(isClientToken(nameToken));
assert.equal(createClientToken(phoneKey), phoneToken);
assert.notEqual(createClientToken('name:Marcela Abreu'), phoneToken);
assert.doesNotMatch(phoneToken, /99103|6131|\(16\)/);
assert.doesNotMatch(nameToken, /Cliente|Sem|Fone/);
assert.equal(isClientToken('invalid-token'), false);
});
test('scoreTertile scores higher values higher by default', () => {
const values = [10, 20, 30, 40, 50];
@@ -311,6 +330,154 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
assert.equal(byKey.get('lost').rfmScore, '113');
});
test('getClientAnalytics returns opaque client tokens', async () => {
const originalQuery = pool.query;
const calls = [];
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('INSERT INTO client_identity_tokens')) {
return { rows: [] };
}
return {
rows: [
{
customer_key: '(16) 99103-6131',
name: 'Marcela Abreu',
phone: '(16) 99103-6131',
quantity_purchased: 10,
total_spent: 500,
order_count: 2,
last_purchase_date: '2026-06-15'
},
{
customer_key: 'name:Cliente Sem Fone',
name: 'Cliente Sem Fone',
phone: null,
quantity_purchased: 1,
total_spent: 50,
order_count: 1,
last_purchase_date: '2026-06-10'
}
]
};
};
try {
const clients = await getClientAnalytics({ start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 2);
assert.equal(clients.length, 2);
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
assert.deepEqual(calls[1].params, [
'(16) 99103-6131',
clients[0].clientToken,
'name:Cliente Sem Fone',
clients[1].clientToken
]);
assert.doesNotMatch(clients[0].clientToken, /99103|6131|Marcela/);
assert.doesNotMatch(clients[1].clientToken, /Cliente|Fone/);
assert.ok(isClientToken(clients[0].clientToken));
assert.ok(isClientToken(clients[1].clientToken));
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => {
const originalQuery = pool.query;
const calls = [];
const clientToken = createClientToken('name:Cliente Sem Fone');
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('FROM client_identity_tokens')) {
return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] };
}
if (sql.includes('all_time_order_count')) {
return {
rows: [{
name: 'Cliente Sem Fone',
phone: null,
all_time_order_count: 3
}]
};
}
return {
rows: [
{
cliente_nome: 'Cliente Sem Fone',
cliente_fone: null,
data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10',
valor_pedido: 25,
produto_id: 'produto-1',
produto_descricao: 'Produto A',
quantidade: 2,
valor_unitario: 10,
pedido_id: 'pedido-1'
},
{
cliente_nome: 'Cliente Sem Fone',
cliente_fone: null,
data_pedido: '10-06-2026',
data_pedido_date: '2026-06-10',
valor_pedido: 25,
produto_id: 'produto-2',
produto_descricao: 'Produto B',
quantidade: 1,
valor_unitario: 5,
pedido_id: 'pedido-1'
}
]
};
};
try {
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' });
assert.equal(calls.length, 3);
assert.match(calls[0].sql, /FROM client_identity_tokens/);
assert.deepEqual(calls[0].params, [clientToken]);
assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/);
assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']);
assert.match(calls[2].sql, /data_pedido_date >= \$2::date/);
assert.match(calls[2].sql, /data_pedido_date <= \$3::date/);
assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']);
assert.equal(details.clientName, 'Cliente Sem Fone');
assert.equal(details.clientPhone, '');
assert.equal(details.allTimeOrderCount, 3);
assert.equal(details.periodSpent, 25);
assert.equal(details.periodItems, 3);
assert.equal(details.periodOrderCount, 1);
assert.equal(details.periodAverageTicket, 25);
assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]);
assert.equal(details.groupedOrders.length, 1);
assert.equal(details.groupedOrders[0].orderTotal, 25);
assert.equal(details.groupedOrders[0].items.length, 2);
} finally {
pool.query = originalQuery;
}
});
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
const originalQuery = pool.query;
pool.query = async () => {
throw new Error('invalid client token should not query');
};
try {
assert.equal(await getClientDetailsAnalytics('not-a-token', { start: '2026-06-01', end: '2026-06-15' }), null);
} finally {
pool.query = originalQuery;
}
});
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
const originalConnect = pool.connect;
const calls = [];
@@ -452,6 +619,10 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
pool.query = async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('INSERT INTO client_identity_tokens')) {
return { rows: [] };
}
return {
rows: [
{
@@ -482,11 +653,12 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
try {
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
assert.equal(calls.length, 1);
assert.equal(calls.length, 2);
assert.match(calls[0].sql, /GROUP BY customer_key/);
assert.doesNotMatch(calls[0].sql, /recency_days/);
assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/);
assert.deepEqual(calls[0].params, ['2026-06-15']);
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
assert.equal(result.clients.length, 2);
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === ''));