require('dotenv').config(); // --- OAuth Office 365 --- const querystring = require('querystring'); const session = require('express-session'); const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const csv = require('csv-parser'); const fastCsv = require('fast-csv'); const axios = require('axios'); const cors = require('cors'); const app = express(); const upload = multer({ dest: 'uploads/' }); const PORT = process.env.PORT; app.use(cors()); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json()); // Configure sua API_KEY e COOKIE aqui ou via variáveis de ambiente const API_URL = process.env.API_URL; const API_KEY = process.env.API_KEY; const COOKIE = process.env.COOKIE; const HEADERS = { 'api-key': API_KEY, 'Cookie': COOKIE }; // small fetch wrapper for external services (ViaCEP etc.) with basic rate-limiting async function fetchJson(url, opts = {}) { try { const r = await axios.get(url, { timeout: 10000, ...opts }); return r.data; } catch (e) { console.warn(`fetchJson error ${url}: ${e.message}`); return null; } } const BASE_BACKOFF_MS = parseInt(process.env.BASE_BACKOFF_MS || '1000', 10); // backoff inicial para retry const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || '5', 10); const REQUEST_DELAY_MS = parseInt(process.env.REQUEST_DELAY_MS || '250', 10); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // normalize partner sigla to requested labels function normalizePartnerSigla(sigla) { if (!sigla) return sigla; const s = String(sigla).trim(); if (!s) return s; const lowered = s.toLowerCase(); // map these two specific variants to 'Sothis' if (lowered === 'são bernardo do campo - sp' || lowered === 'sao bernardo do campo - sp' || lowered === 'sao bernardo do campo') return 'Sothis'; if (lowered === 'são paulo - sp' || lowered === 'sao paulo - sp' || lowered === 'sao paulo') return 'Sothis'; return s; } // Geocode using Google Geocoding API. Returns { lat, lon } or null async function geocodeWithGoogle(address) { const key = process.env.GOOGLE_API_KEY; if (!key) return null; try { const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${encodeURIComponent(key)}`; const r = await axios.get(url, { timeout: 10000 }); if (r && r.data && Array.isArray(r.data.results) && r.data.results.length > 0) { const loc = r.data.results[0].geometry && r.data.results[0].geometry.location; if (loc && loc.lat !== undefined && loc.lng !== undefined) { return { lat: Number(loc.lat), lon: Number(loc.lng) }; } } return null; } catch (e) { console.warn(`geocodeWithGoogle error for '${address}': ${e.message}`); return null; } } async function getMinDistance(lat, lon) { // tenta várias vezes com backoff exponencial; trata 429 usando Retry-After se disponível let attempt = 0; while (attempt < MAX_RETRIES) { try { // envia também o raio (em metros) - API espera esse parâmetro em várias rotas const resp = await axios.get(API_URL, { headers: HEADERS, params: { raio: 5000, latitude: lat, longitude: lon, "itens[]": ["caixa"], consultarPasta: "S" }, timeout: 10000 }); const data = resp.data; const registros = (data && data.registros) ? data.registros : []; // find registros that have a numeric distancia and keep original object for robust extraction const candidates = registros.map(r => ({ raw: r, distanciaRaw: r && r.distancia })) .map(o => ({ raw: o.raw, num: (o.distanciaRaw !== undefined && o.distanciaRaw !== null && o.distanciaRaw !== '') ? Number(o.distanciaRaw) : null })) .filter(x => x.num !== null && !Number.isNaN(x.num)); if (candidates.length) { candidates.sort((a,b) => a.num - b.num); const best = candidates[0]; const r = best.raw || {}; // robust extraction of pasta sigla with fallbacks let pastaSigla = null; try { if (r.pasta) { if (typeof r.pasta === 'string' && r.pasta.trim()) pastaSigla = r.pasta.trim(); else if (r.pasta.sigla && String(r.pasta.sigla).trim()) pastaSigla = String(r.pasta.sigla).trim(); else if (r.pasta.cidade && r.pasta.cidade.sigla && String(r.pasta.cidade.sigla).trim()) pastaSigla = String(r.pasta.cidade.sigla).trim(); } } catch (e) { pastaSigla = null; } // if closest has no pastaSigla, try find any candidate with non-empty sigla if (!pastaSigla) { for (let j = 0; j < candidates.length; j++) { const rr = candidates[j].raw || {}; try { if (rr.pasta) { if (typeof rr.pasta === 'string' && rr.pasta.trim()) { pastaSigla = rr.pasta.trim(); break; } if (rr.pasta.sigla && String(rr.pasta.sigla).trim()) { pastaSigla = String(rr.pasta.sigla).trim(); break; } if (rr.pasta.cidade && rr.pasta.cidade.sigla && String(rr.pasta.cidade.sigla).trim()) { pastaSigla = String(rr.pasta.cidade.sigla).trim(); break; } } } catch (e) { // continue } } } if (!pastaSigla) console.warn(`[WARN] Nenhuma pasta.sigla encontrada para coordenadas ${lat},${lon} (closest dist ${best.num})`); pastaSigla = normalizePartnerSigla(pastaSigla); return { dist: best.num, pastaSigla }; } // sem distancias válidas return null; } catch (err) { attempt += 1; // se for 429, tente respeitar Retry-After quando disponível if (err.response && err.response.status === 429) { const ra = err.response.headers && (err.response.headers['retry-after'] || err.response.headers['Retry-After']); let waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1); if (ra) { const raSec = parseInt(ra, 10); if (!isNaN(raSec)) waitMs = raSec * 1000; } console.warn(`[WARN] 429 recebido para ${lat},${lon} - aguardando ${waitMs}ms e tentando novamente (attempt ${attempt}/${MAX_RETRIES})`); await sleep(waitMs); continue; } // para outros erros de rede/timeout, aguarda backoff exponencial e tenta de novo const waitMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1); console.warn(`[WARN] Erro ao consultar API para ${lat},${lon}: ${err.message} - backoff ${waitMs}ms (attempt ${attempt}/${MAX_RETRIES})`); await sleep(waitMs); } } // exauriu tentativas console.error(`[ERROR] Exauriu retries para ${lat},${lon}`); return null; } // upload CSV endpoint const jobs = {}; // jobId -> { status, total, processed, download, error } app.post('/upload', upload.single('csvfile'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado' }); const filePath = req.file.path; const jobId = `${Date.now()}-${Math.random().toString(36).slice(2,8)}`; jobs[jobId] = { status: 'queued', total: 0, processed: 0, download: null, error: null }; (async () => { jobs[jobId].status = 'processing'; try { const rows = []; await new Promise((resolve, reject) => { fs.createReadStream(filePath) .pipe(csv({ separator: ';' })) .on('data', (data) => rows.push(data)) .on('end', resolve) .on('error', reject); }); jobs[jobId].total = rows.length; const coordCache = new Map(); const outRows = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; // normalize keys to avoid duplicates caused by different headers const norm = {}; Object.keys(row).forEach(k => { // normalize header: lowercase, remove diacritics and non-alphanumeric const kn = k.trim().toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]/g, ''); norm[kn] = row[k]; }); // Input columns (from normalized map) const rawCep = norm['cep'] ? String(norm['cep']).replace(/\D/g, '') : ''; const rawNumero = norm['numero'] ? String(norm['numero']).trim() : ''; // prefer lat/lon from normalized input if available const rawLat = norm['latitude'] || norm['lat'] || null; const rawLon = norm['longitude'] || norm['lon'] || norm['long'] || null; // Prefer existing lat/lon if provided from normalized fields let lat = null, lon = null; if (rawLat && rawLon) { lat = Number(String(rawLat).replace(',', '.')); lon = Number(String(rawLon).replace(',', '.')); } let builtAddress = ''; // If no coords, try ViaCEP -> Google if (!Number.isFinite(lat) || !Number.isFinite(lon)) { if (rawCep) { const cep8 = rawCep.padStart(8, '0'); const viaCepData = await fetchJson(`https://viacep.com.br/ws/${cep8}/json/`); if (viaCepData && !viaCepData.erro) { const logradouro = viaCepData.logradouro || ''; const bairro = viaCepData.bairro || ''; const cidade = viaCepData.localidade || ''; const uf = viaCepData.uf || ''; if (logradouro) { builtAddress = `${logradouro}, ${rawNumero}, ${bairro}, ${cidade} - ${uf}`.replace(/, ,/g, ',').replace(/^,\s*/, ''); } else { // fallback: use neighborhood/city builtAddress = `${bairro || ''} ${cidade ? ', ' + cidade : ''} ${uf ? '- ' + uf : ''}`.trim(); } // build addressToUse (builtAddress already assembled above) if (!process.env.GOOGLE_API_KEY) { console.error('[ERROR] GOOGLE_API_KEY não definida. Não será possível geocodificar. Defina a chave no .env ou em process.env'); } else { const addressToUse = builtAddress || `${cidade} ${uf} ${cep8}`; const geo = await geocodeWithGoogle(addressToUse); if (geo) { lat = geo.lat; lon = geo.lon; } else console.warn(`Google Geocoding não retornou resultado para '${addressToUse}' (CEP ${cep8}, row ${i+1})`); } } else { console.warn(`ViaCEP erro for CEP ${rawCep} (row ${i+1})`); } } else { console.log(`Row ${i+1}: missing/invalid CEP -> '${rawCep}'`); } } // Prepare explicit output row to avoid extra columns const out = { 'CEP': rawCep || '', 'Número': rawNumero || '', 'Endereço': builtAddress || '', // write lat/lon as strings with dot decimal and fixed precision to avoid locale swaps 'Latitude': Number.isFinite(lat) ? Number(lat).toFixed(6) : '', 'Longitude': Number.isFinite(lon) ? Number(lon).toFixed(6) : '', 'Não dedicado': '', 'Dedicado': '', 'Distancia': '', 'Parceiro/Sothis': '' }; if (Number.isFinite(lat) && Number.isFinite(lon)) { const coordKey = `${lat.toFixed(6)},${lon.toFixed(6)}`; if (coordCache.has(coordKey)) { const cached = coordCache.get(coordKey); // cached is either null or { dist, pastaSigla } if (cached !== null) { const d = cached.dist; const di = Math.round(Number(d)); out['Não dedicado'] = di <= 500 ? 'viável' : 'Não viável'; out['Dedicado'] = di <= 1000 ? 'viável' : 'Não viável'; out['Distancia'] = `${di}M`; out['Parceiro/Sothis'] = normalizePartnerSigla(cached.pastaSigla) || ''; } else { out['Não dedicado'] = 'Não viável'; out['Dedicado'] = 'Não viável'; out['Distancia'] = '5km +'; out['Parceiro/Sothis'] = ''; } } else { const minResult = await getMinDistance(lat, lon); // { dist, pastaSigla } or null coordCache.set(coordKey, minResult); if (minResult !== null) { const di = Math.round(Number(minResult.dist)); out['Não dedicado'] = di <= 500 ? 'viável' : 'Não viável'; out['Dedicado'] = di <= 1000 ? 'viável' : 'Não viável'; out['Distancia'] = `${di}M`; out['Parceiro/Sothis'] = normalizePartnerSigla(minResult.pastaSigla) || ''; } else { out['Não dedicado'] = 'Não viável'; out['Dedicado'] = 'Não viável'; out['Distancia'] = '5km +'; out['Parceiro/Sothis'] = ''; } await sleep(REQUEST_DELAY_MS); } } else { // no coords available -> keep defaults } outRows.push(out); jobs[jobId].processed = i + 1; } // write output csv - use explicit outRows and fixed header order const outPath = path.join(__dirname, 'outputs'); if (!fs.existsSync(outPath)) fs.mkdirSync(outPath); const originalName = (req.file && req.file.originalname) ? req.file.originalname : `upload_${Date.now()}.csv`; const parsed = path.parse(originalName); let outBase = `${parsed.name}_output`; let outFile = path.join(outPath, `${outBase}.csv`); if (fs.existsSync(outFile)) { outFile = path.join(outPath, `${outBase}_${Date.now()}.csv`); } const headers = ['CEP','Número','Endereço','Latitude','Longitude','Não dedicado','Dedicado','Distancia','Parceiro/Sothis']; await new Promise((resolve, reject) => { const ws = fs.createWriteStream(outFile); ws.write('\uFEFF'); fastCsv.write(outRows, { headers: headers, delimiter: ';' }).pipe(ws).on('finish', resolve).on('error', reject); }); try { fs.unlinkSync(filePath); } catch (e) {} jobs[jobId].status = 'done'; jobs[jobId].download = `/download/${path.basename(outFile)}`; } catch (err) { console.error(err); jobs[jobId].status = 'error'; jobs[jobId].error = String(err.message || err); } })(); return res.json({ jobId }); }); // download endpoint app.get('/download/:name', (req, res) => { const name = req.params.name; const p = path.join(__dirname, 'outputs', name); if (!fs.existsSync(p)) return res.status(404).send('Arquivo não encontrado'); res.download(p); }); // job status endpoint app.get('/status/:jobId', (req, res) => { const job = jobs[req.params.jobId]; if (!job) return res.status(404).json({ error: 'job não encontrado' }); return res.json(job); }); // manual query endpoint // /consulta now accepts either latitude+longitude OR cep+numero. If cep is provided we resolve ViaCEP -> Google -> Geogrid app.get('/consulta', async (req, res) => { const { latitude: rawLat, longitude: rawLon, cep: rawCep, numero: rawNumero } = req.query; // If cep provided, use ViaCEP -> Google geocoding -> Geogrid if (rawCep) { const cep = String(rawCep).replace(/\D/g, ''); const numero = rawNumero ? String(rawNumero).trim() : ''; try { const viaCepData = await fetchJson(`https://viacep.com.br/ws/${cep}/json/`); if (!viaCepData || viaCepData.erro) return res.status(404).json({ error: 'CEP não encontrado' }); const logradouro = viaCepData.logradouro || ''; const bairro = viaCepData.bairro || ''; const cidade = viaCepData.localidade || ''; const uf = viaCepData.uf || ''; const endereco = `${logradouro}, ${numero}, ${bairro}, ${cidade} - ${uf}`.replace(/, ,/g, ',').replace(/^,\s*/, ''); if (!process.env.GOOGLE_API_KEY) return res.status(500).json({ error: 'GOOGLE_API_KEY não definida no servidor' }); const geo = await geocodeWithGoogle(endereco || `${cidade} ${uf} ${cep}`); if (!geo) return res.status(404).json({ error: 'geocode não encontrado (Google)' }); const lat = Number(geo.lat); const lon = Number(geo.lon); const result = await getMinDistance(lat, lon); if (result && result.dist !== undefined) { return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, parceiro: result.pastaSigla || '' }); } return res.json({ endereco, latitude: lat, longitude: lon, distancia: '5km +' }); } catch (err) { console.error(err); return res.status(500).json({ error: 'Erro na consulta' }); } } // Otherwise require latitude+longitude if (!rawLat || !rawLon) return res.status(400).json({ error: 'latitude e longitude são obrigatórios (ou forneça cep)' }); const latitude = Number(String(rawLat).replace(',', '.')); const longitude = Number(String(rawLon).replace(',', '.')); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { console.warn(`Consulta manual com parâmetros inválidos: lat='${rawLat}' lon='${rawLon}'`); return res.status(400).json({ error: 'latitude ou longitude inválidos' }); } try { console.log(`Consulta manual: lat=${latitude} lon=${longitude}`); const result = await getMinDistance(latitude, longitude); console.log(`Resultado consulta manual: ${JSON.stringify(result)}`); if (result && result.dist !== undefined) { return res.json({ distancia: result.dist, parceiro: result.pastaSigla || '' }); } return res.json({ distancia: '5km +' }); } catch (err) { console.error(err); return res.status(500).json({ error: 'Erro na consulta' }); } }); // manual CEP+Numero query: resolves ViaCEP -> Nominatim -> Geogrid app.get('/consulta-cep', async (req, res) => { const { cep: rawCep, numero: rawNumero } = req.query; if (!rawCep) return res.status(400).json({ error: 'cep é obrigatório' }); const cep = String(rawCep).replace(/\D/g, ''); const numero = rawNumero ? String(rawNumero).trim() : ''; try { const viaCepData = await fetchJson(`https://viacep.com.br/ws/${cep}/json/`); if (!viaCepData || viaCepData.erro) return res.status(404).json({ error: 'CEP não encontrado' }); const logradouro = viaCepData.logradouro || ''; const bairro = viaCepData.bairro || ''; const cidade = viaCepData.localidade || ''; const uf = viaCepData.uf || ''; const endereco = `${logradouro}, ${numero}, ${bairro}, ${cidade} - ${uf}`.replace(/, ,/g, ',').replace(/^,\s*/, ''); if (!process.env.GOOGLE_API_KEY) return res.status(500).json({ error: 'GOOGLE_API_KEY não definida no servidor' }); const geo = await geocodeWithGoogle(endereco || `${cidade} ${uf} ${cep}`); if (!geo) return res.status(404).json({ error: 'geocode não encontrado (Google)' }); const lat = Number(geo.lat); const lon = Number(geo.lon); const result = await getMinDistance(lat, lon); if (result && result.dist !== undefined) { return res.json({ endereco, latitude: lat, longitude: lon, distancia: result.dist, parceiro: result.pastaSigla || '' }); } return res.json({ endereco, latitude: lat, longitude: lon, distancia: '5km +' }); } catch (err) { console.error(err); return res.status(500).json({ error: 'erro na consulta' }); } }); // Configura sessão (precisa vir antes das rotas) app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false })); // Middleware simples pra proteger rotas function ensureAuthenticated(req, res, next) { if (req.session.user) return next(); return res.redirect('/login'); } // === LOGIN COM OFFICE 365 === // Inicia o login (redireciona pro Microsoft) app.get('/login', (req, res) => { const authUrl = new URL(`https://login.microsoftonline.com/${process.env.OAUTH_TENANT_ID}/oauth2/v2.0/authorize`); authUrl.search = new URLSearchParams({ client_id: process.env.OAUTH_CLIENT_ID, response_type: 'code', redirect_uri: process.env.OAUTH_REDIRECT_URI, response_mode: 'query', scope: process.env.OAUTH_SCOPES, state: '12345' }).toString(); res.redirect(authUrl); }); // Callback chamado pela Microsoft após login app.get('/auth/callback', async (req, res) => { const code = req.query.code; if (!code) return res.status(400).send('Código de autorização não encontrado.'); try { // Troca o código pelo token const tokenUrl = `https://login.microsoftonline.com/${process.env.OAUTH_TENANT_ID}/oauth2/v2.0/token`; const body = querystring.stringify({ client_id: process.env.OAUTH_CLIENT_ID, scope: process.env.OAUTH_SCOPES, code, redirect_uri: process.env.OAUTH_REDIRECT_URI, grant_type: 'authorization_code', client_secret: process.env.OAUTH_CLIENT_SECRET }); const tokenResponse = await axios.post(tokenUrl, body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); const accessToken = tokenResponse.data.access_token; // Busca dados do usuário logado (Graph API) const userResponse = await axios.get('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${accessToken}` } }); // Guarda usuário e token na sessão req.session.user = userResponse.data; req.session.accessToken = accessToken; console.log('Usuário logado:', userResponse.data); // Redireciona para a página principal autenticada res.redirect('/home'); } catch (error) { console.error('Erro no login OAuth:', error.response?.data || error.message); res.status(500).send('Erro ao autenticar com Office 365.'); } }); // Página protegida (somente logados) app.get('/home', ensureAuthenticated, (req, res) => { const user = req.session.user; res.send(`
Email: ${user.mail || user.userPrincipalName}
Sair `); }); // Logout (remove sessão) app.get('/logout', (req, res) => { req.session.destroy(() => { res.redirect('/'); }); }); // Inicia o servidor app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));