'use node'; import { action } from '../_generated/server'; import { v } from 'convex/values'; import { api, internal } from '../_generated/api'; import { decryptSMTPPassword } from '../auth/utils'; interface OpenFoodFactsProduct { product?: { product_name?: string; product_name_pt?: string; generic_name?: string; generic_name_pt?: string; categories?: string; categories_tags?: string[]; image_url?: string; image_front_url?: string; image_front_small_url?: string; brands?: string; quantity?: string; packaging?: string; }; status?: number; status_verbose?: string; } interface GS1BrasilProduct { gtin?: string; description?: string; brand?: string; brandName?: string; category?: string; categoryName?: string; imageUrl?: string; image?: string; ncm?: string; unidadeMedida?: string; pesoBruto?: string; } interface BluesoftProduct { gtin?: number | string; description?: string; brand?: { name?: string; picture?: string; }; gpc?: { code?: string; description?: string; }; ncm?: { code?: string; description?: string; full_description?: string; }; thumbnail?: string; price?: string; avg_price?: number; max_price?: number; gross_weight?: number; net_weight?: number; height?: number; width?: number; length?: number; } interface ProductSearchProduct { gtin?: string; name?: string; description?: string; brand?: string; category?: string; imageUrl?: string; image?: string; } interface BuscaUnificadaProduct { produto?: { nome?: string; descricao?: string; categoria?: string; marca?: string; imagem?: string; imagemUrl?: string; ean?: string; }; sucesso?: boolean; mensagem?: string; } interface ProductInfo { nome?: string; descricao?: string; categoria?: string; imagemUrl?: string; marca?: string; quantidade?: string; embalagem?: string; fonte?: string; } interface GS1TokenResponse { access_token?: string; token_type?: string; expires_in?: number; } /** * Busca informações de produto via múltiplas APIs externas * Fontes: Open Food Facts, Busca Unificada, GS1 Brasil, Bluesoft Cosmo, Product-Search.net * * A busca é feita em paralelo em todas as fontes disponíveis e retorna * o primeiro resultado válido encontrado. */ interface ConfigBuscaCodigoBarras { gs1BrasilClientId?: string; gs1BrasilClientSecret?: string; gs1BrasilTokenUrl?: string; gs1BrasilApiUrl?: string; gs1BrasilAtivo: boolean; bluesoftApiKey?: string; bluesoftApiUrl?: string; bluesoftAtivo: boolean; productSearchApiKey?: string; productSearchApiUrl?: string; productSearchAtivo: boolean; } export const buscarInfoProdutoPorCodigoBarras = action({ args: { codigoBarras: v.string() }, handler: async (ctx, args): Promise => { const { codigoBarras } = args; // Validar formato básico de código de barras (EAN-13, UPC, etc.) if (!codigoBarras || codigoBarras.length < 8 || codigoBarras.length > 14) { return null; } // Obter configurações do banco de dados const configDb = await ctx.runQuery( internal.configuracaoBuscaCodigoBarras.obterConfigBuscaCodigoBarrasInternal ); // Preparar configurações (com descriptografia de credenciais) let config: ConfigBuscaCodigoBarras = { gs1BrasilAtivo: false, bluesoftAtivo: false, productSearchAtivo: false }; if (configDb) { // Descriptografar credenciais se existirem let gs1BrasilClientSecret: string | undefined; if (configDb.gs1BrasilClientSecret) { try { gs1BrasilClientSecret = await decryptSMTPPassword(configDb.gs1BrasilClientSecret); } catch (error) { console.error('Erro ao descriptografar GS1 Client Secret:', error); } } let bluesoftApiKey: string | undefined; if (configDb.bluesoftApiKey) { try { bluesoftApiKey = await decryptSMTPPassword(configDb.bluesoftApiKey); } catch (error) { console.error('Erro ao descriptografar Bluesoft API Key:', error); } } let productSearchApiKey: string | undefined; if (configDb.productSearchApiKey) { try { productSearchApiKey = await decryptSMTPPassword(configDb.productSearchApiKey); } catch (error) { console.error('Erro ao descriptografar Product-Search API Key:', error); } } config = { gs1BrasilClientId: configDb.gs1BrasilClientId, gs1BrasilClientSecret, gs1BrasilTokenUrl: configDb.gs1BrasilTokenUrl, gs1BrasilApiUrl: configDb.gs1BrasilApiUrl, gs1BrasilAtivo: configDb.gs1BrasilAtivo, bluesoftApiKey, bluesoftApiUrl: configDb.bluesoftApiUrl, bluesoftAtivo: configDb.bluesoftAtivo, productSearchApiKey, productSearchApiUrl: configDb.productSearchApiUrl, productSearchAtivo: configDb.productSearchAtivo }; } // Tentar buscar em todas as fontes em paralelo // Usamos Promise.allSettled para não falhar se uma fonte falhar const resultados = await Promise.allSettled([ buscarOpenFoodFacts(codigoBarras), // 0 buscarBuscaUnificada(codigoBarras), // 1 - Gratuita, sem credenciais buscarGS1Brasil(codigoBarras, config), // 2 buscarBluesoftCosmo(codigoBarras, config), // 3 buscarProductSearch(codigoBarras, config) // 4 ]); // Processar resultados e retornar o primeiro que tiver dados válidos // Prioridade: GS1 Brasil > Bluesoft > Product-Search > Busca Unificada > Open Food Facts const ordemPrioridade = [2, 3, 4, 1, 0]; // Índices das fontes por prioridade for (const indice of ordemPrioridade) { const resultado = resultados[indice]; if (resultado.status === 'fulfilled' && resultado.value) { return resultado.value; } } // Se nenhuma fonte retornou dados, retornar null return null; } }); /** * Busca na API de Produtos por Código de Barras (Busca Unificada) * Gratuita, sem necessidade de credenciais * Site: https://api-produtos.seunegocionanuvem.com.br */ async function buscarBuscaUnificada(codigoBarras: string): Promise { try { const apiUrl = 'https://api-produtos.seunegocionanuvem.com.br/api/produtos'; const response = await fetch(`${apiUrl}?ean=${codigoBarras}`, { method: 'GET', headers: { 'Content-Type': 'application/json', 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' }, signal: AbortSignal.timeout(5000) }); if (!response.ok) { return null; } const data = (await response.json()) as BuscaUnificadaProduct; if (!data.produto || (!data.produto.nome && !data.produto.descricao)) { return null; } const produto = data.produto; const info: ProductInfo = { nome: produto.nome || undefined, descricao: produto.descricao || undefined, categoria: produto.categoria || undefined, imagemUrl: produto.imagemUrl || produto.imagem || undefined, marca: produto.marca || undefined, fonte: 'Busca Unificada' }; if (info.nome || info.descricao) { return info; } return null; } catch (error) { console.error('Erro ao buscar Busca Unificada:', error); return null; } } /** * Busca na Open Food Facts (gratuita, sem autenticação) */ async function buscarOpenFoodFacts(codigoBarras: string): Promise { try { const response = await fetch( `https://world.openfoodfacts.org/api/v0/product/${codigoBarras}.json`, { method: 'GET', headers: { 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' }, signal: AbortSignal.timeout(5000) } ); if (!response.ok) { return null; } const data = (await response.json()) as OpenFoodFactsProduct; if (data.status !== 1 || !data.product) { return null; } const product = data.product; // Extrair categoria let categoria: string | undefined; if (product.categories_tags && product.categories_tags.length > 0) { const primeiraCategoria = product.categories_tags[0]; categoria = primeiraCategoria .replace(/^pt:/, '') .replace(/^en:/, '') .replace(/-/g, ' ') .split(' ') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } else if (product.categories) { categoria = product.categories.split(',')[0].trim(); } const info: ProductInfo = { nome: product.product_name_pt || product.product_name || undefined, descricao: product.generic_name_pt || product.generic_name || undefined, categoria, imagemUrl: product.image_front_url || product.image_url || product.image_front_small_url || undefined, marca: product.brands || undefined, quantidade: product.quantity || undefined, embalagem: product.packaging || undefined, fonte: 'Open Food Facts' }; if (info.nome || info.descricao) { return info; } return null; } catch (error) { console.error('Erro ao buscar Open Food Facts:', error); return null; } } /** * Obtém token de autenticação da GS1 Brasil */ async function obterTokenGS1Brasil(config: ConfigBuscaCodigoBarras): Promise { try { // Priorizar configurações do banco, fallback para variáveis de ambiente const clientId = config.gs1BrasilClientId || process.env.GS1_BRASIL_CLIENT_ID; const clientSecret = config.gs1BrasilClientSecret || process.env.GS1_BRASIL_CLIENT_SECRET; const tokenUrl = config.gs1BrasilTokenUrl || process.env.GS1_BRASIL_TOKEN_URL || 'https://apicnp.gs1br.org/oauth/token'; if (!clientId || !clientSecret) { console.warn('GS1 Brasil: Credenciais não configuradas'); return null; } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }), signal: AbortSignal.timeout(5000) }); if (!response.ok) { console.error('GS1 Brasil: Erro ao obter token', response.status); return null; } const data = (await response.json()) as GS1TokenResponse; return data.access_token || null; } catch (error) { console.error('GS1 Brasil: Erro ao obter token:', error); return null; } } /** * Busca na GS1 Brasil (API Verified by GS1) */ async function buscarGS1Brasil( codigoBarras: string, config: ConfigBuscaCodigoBarras ): Promise { try { if (!config.gs1BrasilAtivo) { return null; } const token = await obterTokenGS1Brasil(config); if (!token) { return null; } const apiUrl = config.gs1BrasilApiUrl || process.env.GS1_BRASIL_API_URL || 'https://apicnp.gs1br.org/api/v1/products'; const response = await fetch(`${apiUrl}/${codigoBarras}`, { method: 'GET', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' }, signal: AbortSignal.timeout(5000) }); if (!response.ok) { if (response.status === 404) { return null; // Produto não encontrado } console.error('GS1 Brasil: Erro na consulta', response.status); return null; } const data = (await response.json()) as GS1BrasilProduct; if (!data.gtin && !data.description) { return null; } const info: ProductInfo = { nome: data.description || undefined, descricao: data.description || undefined, categoria: data.categoryName || data.category || undefined, imagemUrl: data.imageUrl || data.image || undefined, marca: data.brandName || data.brand || undefined, quantidade: data.unidadeMedida || undefined, fonte: 'GS1 Brasil' }; if (info.nome || info.descricao) { return info; } return null; } catch (error) { console.error('Erro ao buscar GS1 Brasil:', error); return null; } } /** * Busca na Bluesoft Cosmos * Documentação oficial: https://api.cosmos.bluesoft.com.br/api * Endpoint: GET /gtins/{código}.json */ async function buscarBluesoftCosmo( codigoBarras: string, config: ConfigBuscaCodigoBarras ): Promise { try { if (!config.bluesoftAtivo) { return null; } // Priorizar configurações do banco, fallback para variáveis de ambiente const apiKey = config.bluesoftApiKey || process.env.BLUESOFT_API_KEY; // Endpoint base da API - pode ser configurado, mas o padrão é o oficial const apiBaseUrl = config.bluesoftApiUrl || process.env.BLUESOFT_API_URL || 'https://api.cosmos.bluesoft.com.br'; if (!apiKey) { console.warn('Bluesoft Cosmos: API Key não configurada'); return null; } // Endpoint correto conforme documentação: /gtins/{codigo}.json const url = `${apiBaseUrl.replace(/\/$/, '')}/gtins/${codigoBarras}.json`; const response = await fetch(url, { method: 'GET', headers: { 'X-Cosmos-Token': apiKey, 'Content-Type': 'application/json', 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' // User-Agent é obrigatório }, signal: AbortSignal.timeout(10000) // 10 segundos de timeout }); if (!response.ok) { const errorText = await response.text().catch(() => 'Não foi possível ler resposta'); if (response.status === 404) { // Produto não encontrado é normal, não é erro return null; } if (response.status === 401 || response.status === 403) { console.error('Bluesoft Cosmos: Erro de autenticação. Verifique a API Key e User-Agent.'); console.error('Bluesoft Cosmos: Detalhes do erro', { status: response.status, statusText: response.statusText, body: errorText.substring(0, 500) }); return null; } if (response.status === 429) { console.error('Bluesoft Cosmos: Limite de requisições excedido (HTTP 429)'); return null; } console.error('Bluesoft Cosmos: Erro na consulta', { status: response.status, statusText: response.statusText, url, body: errorText.substring(0, 500) }); return null; } const data = (await response.json()) as BluesoftProduct; // Validar se temos dados mínimos if (!data.gtin && !data.description) { return null; } // Mapear campos conforme estrutura da API const info: ProductInfo = { nome: data.description || undefined, descricao: data.description || undefined, categoria: data.gpc?.description || data.ncm?.description || undefined, imagemUrl: data.thumbnail || undefined, marca: data.brand?.name || undefined, quantidade: data.net_weight ? `${data.net_weight}g` : data.gross_weight ? `${data.gross_weight}g` : undefined, fonte: 'Bluesoft Cosmos' }; if (info.nome || info.descricao) { return info; } return null; } catch (error) { console.error('Bluesoft Cosmos: Erro ao buscar:', error); if (error instanceof Error) { console.error('Bluesoft Cosmos: Detalhes do erro:', error.message); } return null; } } /** * Busca no Product-Search.net * Pode funcionar com ou sem API key dependendo do plano */ async function buscarProductSearch( codigoBarras: string, config: ConfigBuscaCodigoBarras ): Promise { try { if (!config.productSearchAtivo) { return null; } // Priorizar configurações do banco, fallback para variáveis de ambiente const apiKey = config.productSearchApiKey || process.env.PRODUCT_SEARCH_API_KEY; const apiUrl = config.productSearchApiUrl || process.env.PRODUCT_SEARCH_API_URL || 'https://api.product-search.net/v1/products'; const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' }; // Adicionar API key se disponível if (apiKey) { headers['Authorization'] = `Bearer ${apiKey}`; } const response = await fetch(`${apiUrl}/${codigoBarras}`, { method: 'GET', headers, signal: AbortSignal.timeout(5000) }); if (!response.ok) { if (response.status === 404) { return null; // Produto não encontrado } console.error('Product-Search.net: Erro na consulta', response.status); return null; } const data = (await response.json()) as ProductSearchProduct; if (!data.gtin && !data.name && !data.description) { return null; } const info: ProductInfo = { nome: data.name || data.description || undefined, descricao: data.description || undefined, categoria: data.category || undefined, imagemUrl: data.imageUrl || data.image || undefined, marca: data.brand || undefined, fonte: 'Product-Search.net' }; if (info.nome || info.descricao) { return info; } return null; } catch (error) { console.error('Erro ao buscar Product-Search.net:', error); return null; } }