Add native Olist composition sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
This commit is contained in:
389
backend/services/olistService.js
Normal file
389
backend/services/olistService.js
Normal file
@@ -0,0 +1,389 @@
|
||||
const crypto = require('crypto');
|
||||
const { pool } = require('../db');
|
||||
const {
|
||||
OLIST_CLIENT_ID,
|
||||
OLIST_CLIENT_SECRET,
|
||||
OLIST_FRONTEND_URL,
|
||||
OLIST_REDIRECT_URI,
|
||||
OLIST_SYNC_INTERVAL_MS,
|
||||
OLIST_SYNC_REQUESTS_PER_MINUTE,
|
||||
OLIST_TOKEN_ENCRYPTION_KEY
|
||||
} = require('../config');
|
||||
const { upsertTinyProductComposition } = require('./productionOrderService');
|
||||
|
||||
const OLIST_API_URL = 'https://api.tiny.com.br/public-api/v3';
|
||||
const OLIST_TOKEN_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/token';
|
||||
const OLIST_AUTHORIZE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/auth';
|
||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const RUN_LOCK_KEY = 8_371_204;
|
||||
let activeSync = null;
|
||||
let scheduledSync = null;
|
||||
|
||||
const normalizeText = (value) => String(value || '').trim();
|
||||
const stateHash = (state) => crypto.createHash('sha256').update(state).digest('hex');
|
||||
|
||||
const getEncryptionKey = () => {
|
||||
const raw = normalizeText(OLIST_TOKEN_ENCRYPTION_KEY);
|
||||
if (/^[a-f0-9]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
|
||||
try {
|
||||
const decoded = Buffer.from(raw, 'base64');
|
||||
return decoded.length === 32 ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isOlistConfigured = () => Boolean(
|
||||
normalizeText(OLIST_CLIENT_ID)
|
||||
&& normalizeText(OLIST_CLIENT_SECRET)
|
||||
&& normalizeText(OLIST_REDIRECT_URI)
|
||||
&& normalizeText(OLIST_FRONTEND_URL)
|
||||
&& getEncryptionKey()
|
||||
);
|
||||
|
||||
const configuredError = () => {
|
||||
const error = new Error('Configure as credenciais Olist V3 e a chave de criptografia no ambiente antes de conectar.');
|
||||
error.statusCode = 409;
|
||||
return error;
|
||||
};
|
||||
|
||||
const assertConfigured = () => {
|
||||
if (!isOlistConfigured()) throw configuredError();
|
||||
};
|
||||
|
||||
const encrypt = (value) => {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) throw configuredError();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
ciphertext: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString('base64'),
|
||||
iv: iv.toString('base64')
|
||||
};
|
||||
};
|
||||
|
||||
const decrypt = ({ ciphertext, iv }) => {
|
||||
const key = getEncryptionKey();
|
||||
if (!key) throw configuredError();
|
||||
const payload = Buffer.from(ciphertext, 'base64');
|
||||
const authTag = payload.subarray(-16);
|
||||
const encrypted = payload.subarray(0, -16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
};
|
||||
|
||||
const mapRun = (row) => ({
|
||||
id: row.id,
|
||||
trigger: row.trigger,
|
||||
mode: row.mode,
|
||||
status: row.status,
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
productsScanned: Number(row.products_scanned || 0),
|
||||
manufacturedProducts: Number(row.manufactured_products || 0),
|
||||
imported: Number(row.imported || 0),
|
||||
componentCount: Number(row.component_count || 0),
|
||||
referenceCount: Number(row.reference_count || 0),
|
||||
skippedReferenceCount: Number(row.skipped_reference_count || 0),
|
||||
failed: Number(row.failed || 0),
|
||||
errorMessage: row.error_message || '',
|
||||
details: row.details || []
|
||||
});
|
||||
|
||||
const getConnection = async () => {
|
||||
const result = await pool.query('SELECT * FROM olist_connections WHERE id = 1;');
|
||||
return result.rows[0] || null;
|
||||
};
|
||||
|
||||
const listSyncRuns = async (limit = 5) => {
|
||||
const result = await pool.query(`
|
||||
SELECT * FROM olist_sync_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $1;
|
||||
`, [Math.max(1, Math.min(Number(limit) || 5, 20))]);
|
||||
return result.rows.map(mapRun);
|
||||
};
|
||||
|
||||
const getOlistStatus = async () => {
|
||||
const [connection, runs] = await Promise.all([getConnection(), listSyncRuns()]);
|
||||
return {
|
||||
configured: isOlistConfigured(),
|
||||
connected: Boolean(connection),
|
||||
tokenExpiresAt: connection?.token_expires_at || null,
|
||||
syncInProgress: Boolean(activeSync),
|
||||
runs
|
||||
};
|
||||
};
|
||||
|
||||
const createAuthorizationUrl = async () => {
|
||||
assertConfigured();
|
||||
const state = crypto.randomBytes(32).toString('base64url');
|
||||
await pool.query('DELETE FROM olist_oauth_states WHERE expires_at <= CURRENT_TIMESTAMP;');
|
||||
await pool.query(`
|
||||
INSERT INTO olist_oauth_states (state_hash, expires_at)
|
||||
VALUES ($1, $2);
|
||||
`, [stateHash(state), new Date(Date.now() + OAUTH_STATE_TTL_MS)]);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: OLIST_CLIENT_ID,
|
||||
redirect_uri: OLIST_REDIRECT_URI,
|
||||
scope: 'openid',
|
||||
response_type: 'code',
|
||||
state
|
||||
});
|
||||
return `${OLIST_AUTHORIZE_URL}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const exchangeToken = async (params) => {
|
||||
const response = await fetch(OLIST_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: OLIST_CLIENT_ID,
|
||||
client_secret: OLIST_CLIENT_SECRET,
|
||||
...params
|
||||
})
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.access_token) {
|
||||
const error = new Error(data?.error_description || data?.error || 'Não foi possível autorizar a conexão com a Olist.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const persistTokens = async ({ accessToken, refreshToken, expiresIn, existingConnection }) => {
|
||||
const access = encrypt(accessToken);
|
||||
const refresh = refreshToken ? encrypt(refreshToken) : {
|
||||
ciphertext: existingConnection.refresh_token_ciphertext,
|
||||
iv: existingConnection.refresh_token_iv
|
||||
};
|
||||
const expiresAt = new Date(Date.now() + Math.max(Number(expiresIn) || 14_400, 60) * 1000);
|
||||
await pool.query(`
|
||||
INSERT INTO olist_connections (
|
||||
id, access_token_ciphertext, access_token_iv, refresh_token_ciphertext,
|
||||
refresh_token_iv, token_expires_at, connected_at, updated_at
|
||||
)
|
||||
VALUES (1, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET access_token_ciphertext = EXCLUDED.access_token_ciphertext,
|
||||
access_token_iv = EXCLUDED.access_token_iv,
|
||||
refresh_token_ciphertext = EXCLUDED.refresh_token_ciphertext,
|
||||
refresh_token_iv = EXCLUDED.refresh_token_iv,
|
||||
token_expires_at = EXCLUDED.token_expires_at,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
`, [access.ciphertext, access.iv, refresh.ciphertext, refresh.iv, expiresAt]);
|
||||
};
|
||||
|
||||
const completeAuthorization = async ({ code, state }) => {
|
||||
assertConfigured();
|
||||
const result = await pool.query(`
|
||||
DELETE FROM olist_oauth_states
|
||||
WHERE state_hash = $1 AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING state_hash;
|
||||
`, [stateHash(state)]);
|
||||
if (!result.rows.length) {
|
||||
const error = new Error('A autorização da Olist expirou ou é inválida. Tente conectar novamente.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const token = await exchangeToken({
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: OLIST_REDIRECT_URI,
|
||||
code
|
||||
});
|
||||
if (!token.refresh_token) {
|
||||
const error = new Error('A Olist não retornou um refresh token. Verifique as permissões do aplicativo.');
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: {} });
|
||||
};
|
||||
|
||||
const getAccessToken = async (forceRefresh = false) => {
|
||||
assertConfigured();
|
||||
const connection = await getConnection();
|
||||
if (!connection) {
|
||||
const error = new Error('Conecte a conta Olist antes de sincronizar.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!forceRefresh && new Date(connection.token_expires_at).getTime() > Date.now() + 60_000) {
|
||||
return decrypt({ ciphertext: connection.access_token_ciphertext, iv: connection.access_token_iv });
|
||||
}
|
||||
|
||||
const token = await exchangeToken({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: decrypt({ ciphertext: connection.refresh_token_ciphertext, iv: connection.refresh_token_iv })
|
||||
});
|
||||
await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: connection });
|
||||
return token.access_token;
|
||||
};
|
||||
|
||||
const olistRequest = async (path, retry = true) => {
|
||||
const response = await fetch(`${OLIST_API_URL}${path}`, {
|
||||
headers: { Authorization: `Bearer ${await getAccessToken(!retry)}` }
|
||||
});
|
||||
if (response.status === 401 && retry) return olistRequest(path, false);
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.mensagem || `A Olist retornou HTTP ${response.status}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const listOlistProducts = async () => {
|
||||
const products = [];
|
||||
let offset = 0;
|
||||
do {
|
||||
const page = await olistRequest(`/produtos?${new URLSearchParams({ limit: '100', offset: String(offset) })}`);
|
||||
products.push(...(Array.isArray(page?.itens) ? page.itens : []));
|
||||
offset += page?.paginacao?.limit || 100;
|
||||
if (offset >= Number(page?.paginacao?.total || 0)) break;
|
||||
} while (true);
|
||||
return products;
|
||||
};
|
||||
|
||||
const createRun = async ({ trigger, mode }) => {
|
||||
const result = await pool.query(`
|
||||
INSERT INTO olist_sync_runs (trigger, mode, status)
|
||||
VALUES ($1, $2, 'running')
|
||||
RETURNING *;
|
||||
`, [trigger, mode]);
|
||||
return mapRun(result.rows[0]);
|
||||
};
|
||||
|
||||
const finishRun = async (id, summary, error = null) => {
|
||||
const result = await pool.query(`
|
||||
UPDATE olist_sync_runs
|
||||
SET status = $2,
|
||||
completed_at = CURRENT_TIMESTAMP,
|
||||
products_scanned = $3,
|
||||
manufactured_products = $4,
|
||||
imported = $5,
|
||||
component_count = $6,
|
||||
reference_count = $7,
|
||||
skipped_reference_count = $8,
|
||||
failed = $9,
|
||||
error_message = $10,
|
||||
details = $11::jsonb
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
`, [id, error ? 'failed' : 'completed', summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, error?.message || null, JSON.stringify(summary.details.slice(0, 100))]);
|
||||
return mapRun(result.rows[0]);
|
||||
};
|
||||
|
||||
const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
||||
assertConfigured();
|
||||
if (activeSync) return activeSync;
|
||||
|
||||
activeSync = (async () => {
|
||||
const lastSuccessful = (await listSyncRuns(20)).find(run => run.status === 'completed');
|
||||
const mode = fullSync || !lastSuccessful ? 'full' : 'incremental';
|
||||
const run = await createRun({ trigger, mode });
|
||||
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
|
||||
let lockClient;
|
||||
try {
|
||||
lockClient = await pool.connect();
|
||||
const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]);
|
||||
if (!lock.rows[0]?.locked) {
|
||||
const error = new Error('Outra sincronização Olist já está em andamento.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const allProducts = await listOlistProducts();
|
||||
const unitById = new Map(allProducts.map(product => [String(product.id), product.unidade || 'UN']));
|
||||
const changedAfter = mode === 'incremental' ? new Date(lastSuccessful.completedAt || lastSuccessful.startedAt) : null;
|
||||
const candidates = changedAfter
|
||||
? allProducts.filter(product => !product.dataAlteracao || new Date(product.dataAlteracao).getTime() >= changedAfter.getTime())
|
||||
: allProducts;
|
||||
const minimumDelay = Math.ceil(60_000 / Math.max(1, OLIST_SYNC_REQUESTS_PER_MINUTE));
|
||||
|
||||
for (const product of candidates) {
|
||||
summary.productsScanned += 1;
|
||||
try {
|
||||
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
|
||||
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
|
||||
if (!components.length) continue;
|
||||
const result = await upsertTinyProductComposition({
|
||||
tinyId: String(product.id),
|
||||
productSku: product.sku,
|
||||
productDescription: product.descricao,
|
||||
unit: product.unidade || 'UN',
|
||||
rawPayload: { product, structure }
|
||||
}, components.map(component => ({
|
||||
componentTinyId: String(component?.produto?.id || ''),
|
||||
componentSku: component?.produto?.sku || '',
|
||||
componentName: component?.produto?.descricao || '',
|
||||
quantityPerUnit: component?.quantidade,
|
||||
unit: unitById.get(String(component?.produto?.id)) || 'UN'
|
||||
})));
|
||||
summary.manufacturedProducts += 1;
|
||||
summary.imported += 1;
|
||||
summary.componentCount += result.componentCount || 0;
|
||||
summary.referenceCount += result.referenceCount || 0;
|
||||
summary.skippedReferenceCount += result.skippedReferenceCount || 0;
|
||||
} catch (error) {
|
||||
if (error.statusCode !== 400 && error.statusCode !== 404) {
|
||||
summary.failed += 1;
|
||||
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
|
||||
}
|
||||
}
|
||||
await sleep(minimumDelay);
|
||||
}
|
||||
return await finishRun(run.id, summary);
|
||||
} catch (error) {
|
||||
await finishRun(run.id, summary, error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (lockClient) {
|
||||
await lockClient.query('SELECT pg_advisory_unlock($1);', [RUN_LOCK_KEY]).catch(() => {});
|
||||
lockClient.release();
|
||||
}
|
||||
}
|
||||
})().finally(() => { activeSync = null; });
|
||||
|
||||
return activeSync;
|
||||
};
|
||||
|
||||
const startOlistSync = async (options) => {
|
||||
assertConfigured();
|
||||
if (!await getConnection()) {
|
||||
const error = new Error('Conecte a conta Olist antes de sincronizar.');
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
}
|
||||
if (activeSync) return { accepted: false, reason: 'running' };
|
||||
runOlistSync(options).catch(error => console.error('Olist sync failed:', error.message));
|
||||
return { accepted: true };
|
||||
};
|
||||
|
||||
const scheduleOlistSync = () => {
|
||||
if (scheduledSync) clearInterval(scheduledSync);
|
||||
scheduledSync = setInterval(() => {
|
||||
startOlistSync({ trigger: 'scheduled' }).catch(error => console.error('Scheduled Olist sync skipped:', error.message));
|
||||
}, OLIST_SYNC_INTERVAL_MS);
|
||||
return scheduledSync;
|
||||
};
|
||||
|
||||
const getOlistFrontendRedirect = (outcome) => `${OLIST_FRONTEND_URL.replace(/\/$/, '')}/#/registrations?olist=${outcome}`;
|
||||
|
||||
module.exports = {
|
||||
completeAuthorization,
|
||||
createAuthorizationUrl,
|
||||
getOlistFrontendRedirect,
|
||||
getOlistStatus,
|
||||
isOlistConfigured,
|
||||
scheduleOlistSync,
|
||||
startOlistSync
|
||||
};
|
||||
Reference in New Issue
Block a user