Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user