From b3bca576daf23994d45544e5ff62fc85ad6e494f Mon Sep 17 00:00:00 2001 From: "gabriel.pereira" Date: Tue, 30 Dec 2025 09:16:07 -0300 Subject: [PATCH] =?UTF-8?q?REFACTOR:=20Remo=C3=A7=C3=A3o=20de=20servi?= =?UTF-8?q?=C3=A7os=20obsoletos=20e=20implementa=C3=A7=C3=A3o=20da=20nova?= =?UTF-8?q?=20funcionalidade=20de=20viabilidade=20pela=20API=20de=20contra?= =?UTF-8?q?ta=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removidos os arquivos não utilizados: fetchService, geocodeService, normalizeService e retryService. - Adicionado o viabilidadeController para gerenciar consultas de viabilidade e upload de arquivos CSV. - Criadas as viabilidadeRoutes para tratar as rotas da API relacionadas à viabilidade. - Implementado o csvService para processamento de arquivos CSV e integração com as consultas de viabilidade. - Criado o jobStore.service para gerenciamento do estado dos jobs durante o processamento do CSV. - Desenvolvido o viabilidadeService para integração com a API de viabilidade. --- app.js | 723 ++-------------------------- app.old.js | 681 ++++++++++++++++++++++++++ config/apiConfig.js | 5 +- controller/viabilidadeController.js | 54 +++ public/main.js | 22 +- routes/viabilidadeRoutes.js | 24 + server.js | 40 +- service/csvService.js | 145 ++++++ service/fetchService.js | 14 - service/geocodeService.js | 42 -- service/jobStore.service.js | 57 +++ service/normalizeService.js | 13 - service/retryService.js | 18 - service/viabilidadeService.js | 55 +++ 14 files changed, 1117 insertions(+), 776 deletions(-) create mode 100644 app.old.js create mode 100644 controller/viabilidadeController.js create mode 100644 routes/viabilidadeRoutes.js create mode 100644 service/csvService.js delete mode 100644 service/fetchService.js delete mode 100644 service/geocodeService.js create mode 100644 service/jobStore.service.js delete mode 100644 service/normalizeService.js delete mode 100644 service/retryService.js create mode 100644 service/viabilidadeService.js diff --git a/app.js b/app.js index e236d9b..0b06d0e 100644 --- a/app.js +++ b/app.js @@ -1,681 +1,60 @@ -require("dotenv").config(); +const express = require('express'); +const path = require('path'); +const session = require('express-session'); +require('dotenv').config(); -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 session = require("express-session"); // adiciona session -const { geocodeWithGoogle, addressWithGoogle } = require("./service/geocodeService"); -const { fetchJson } = require("./service/fetchService"); -const { BASE_BACKOFF_MS, MAX_RETRIES, REQUEST_DELAY_MS, sleep } = require("./service/retryService"); -const { API_URL, HEADERS } = require("./config/apiConfig"); -const { normalizePartnerSigla } = require("./service/normalizeService"); -const authRoutes = require("./routes/authRoutes.js"); +const viabilidadeRoutes = require('./routes/viabilidadeRoutes'); -function createApp() { - const upload = multer({ dest: "uploads/" }); - const app = express(); +const app = express(); - // se estiver atrás de um reverse proxy (nginx/traefik) em produção, habilite: - app.set("trust proxy", 1); +// Basic middleware +app.use(express.json({ limit: '5mb' })); +app.use(express.urlencoded({ extended: true })); - app.use(cors()); - app.use(express.json()); +// Session (in-memory, fine for dev/tests) +app.use(session({ + secret: process.env.SESSION_SECRET || 'dev-secret', + resave: false, + saveUninitialized: false, + cookie: { secure: false } // secure should be true behind HTTPS in prod +})); - // session TEM que vir antes das rotas que usam req.session - app.use( - session({ - secret: process.env.SESSION_SECRET || "change-me", - resave: false, - saveUninitialized: false, - cookie: { - maxAge: 24 * 60 * 60 * 1000, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - }, - }) - ); - - // -- Desenvolvimento: pular autenticação se configurado - // Para ativar: defina NODE_ENV=development e DEV_SKIP_AUTH=true no .env - if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { - app.use((req, res, next) => { - // garante que exista uma sessão autenticada para facilitar testes locais - if (req.session && (!req.session.user || !req.session.user.authenticated)) { - req.session.user = { authenticated: true, dev: true }; - } - next(); - }); - } - - // middleware que protege rotas que exigem login - function requireAuth(req, res, next) { - if (req.session?.user?.authenticated) { - return next(); - } - - if (req.xhr || req.headers.accept?.includes("application/json")) { - return res.status(401).json({ error: "not_authenticated" }); - } - - return res.redirect("/login"); -} - - - // proteger demais rotas (ex.: /upload, /consulta) +// Dev bypass for Microsoft auth / session (only when explicitly enabled) +if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { app.use((req, res, next) => { - // permissão liberada para rotas de auth já tratadas; proteger o resto - if (req.path.startsWith("/auth") || req.path === "/login") return next(); - return requireAuth(req, res, next); + if (!req.session) return next(); + // mark a simple user in session so handlers that expect auth work in dev + req.session.user = req.session.user || { id: 'dev', name: 'developer' }; + next(); }); - - // redirect raiz - app.get("/", (req, res) => { - // em dev com bypass, sirva a página diretamente (sem redirect) - if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { - return res.sendFile(path.join(__dirname, 'public', 'index.html')); - } - - if (req.session?.user?.authenticated) { - return res.redirect("/public/index.html"); - } - return res.redirect("/login"); - }); - - ///////////////////////////////////////////////////// - - 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) => { - // ignora linhas totalmente vazias (todos os campos nulos/undefined/strings vazias) - const values = Object.values(data); - const allEmpty = - values.length === 0 || - values.every( - (v) => v === null || v === undefined || String(v).trim() === "" - ); - if (!allEmpty) 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 = ""; - - const googleGeocodeAddress = await addressWithGoogle(lat, lon); - if (googleGeocodeAddress) { - builtAddress = googleGeocodeAddress; - } else { - console.warn( - `Google Reverse Geocoding não retornou resultado para coords ${lat},${lon} (row ${i + 1})` - ); - } - - console.log(`Row ${i + 1}: CEP='${rawCep}' Número='${rawNumero}' Lat='${lat}' Lon='${lon}' BuiltAddress='${builtAddress}'`); - - // If no coords, try ViaCEP -> Google - if (!Number.isFinite(lat) || !Number.isFinite(lon)) { - if (rawCep) { - const cep8 = rawCep.padStart(8, "0"); - const cepRestData = await fetch( - 'https://api.cep.rest/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cep: cep8 }) - } - ).then(r => r.json()); - if (cepRestData && !cepRestData.erro) { - const logradouro = cepRestData.data.logradouro || ""; - const bairro = cepRestData.data.bairro || ""; - const cidade = cepRestData.data.localidade || ""; - const uf = cepRestData.data.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 model endpoint -app.get("/download-model/:name", (req, res) => { - const name = req.params.name; - const safeName = path.basename(name); // evita ../ - const filePath = path.join(__dirname, "models", safeName); - if (!fs.existsSync(filePath)) return res.status(404).send("Arquivo não encontrado"); - return res.download(filePath, safeName); - }); - - // lista os arquivos disponíveis em /models - app.get("/download-models/list", (req, res) => { - const modelDir = path.join(__dirname, "models"); - if (!fs.existsSync(modelDir)) return res.status(404).json({ error: "Pasta de modelos não encontrada" }); - const files = fs.readdirSync(modelDir).filter((f) => fs.statSync(path.join(modelDir, f)).isFile()); - return res.json({ files }); - }); - - - - // download result 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 fetch( - 'https://api.cep.rest/', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cep }) - } - ).then(r => r.json()); - if (!viaCepData || viaCepData.erro) - return res.status(404).json({ error: "CEP não encontrado" }); - const logradouro = viaCepData.data.logradouro || ""; - const bairro = viaCepData.data.bairro || ""; - const cidade = viaCepData.data.localidade || ""; - const uf = viaCepData.data.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 cepRestData = await fetch( - 'https://api.cep.rest/', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cep }) - } - ).then(r => r.json()); - if (!cepRestData || cepRestData.erro) - return res.status(404).json({ error: "CEP não encontrado" }); - const logradouro = cepRestData.data.logradouro || ""; - const bairro = cepRestData.data.bairro || ""; - const cidade = cepRestData.data.localidade || ""; - const uf = cepRestData.data.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" }); - } - }); - - //////////////////////////////////////////////////// - - // Servir arquivos estáticos (index.html) - // app.use("/public", express.static(path.join(__dirname, "public"))); - - // Usa as rotas de autenticação - app.use("/", authRoutes); - - // servir arquivos estáticos da pasta public (rotas protegidas já são tratadas pelo middleware global) - app.use(express.static(path.join(__dirname, "public"))); - - // rota protegida que serve o index.html - app.get("/app", requireAuth, (req, res) => { - res.sendFile(path.join(__dirname, "public", "index.html")); - }); - - - ///////////////////////////////////////////////////// - - return app; } -module.exports = { - createApp, -}; +// Serve static assets (UI) +app.use(express.static(path.join(__dirname, 'public'))); + +// Mount API routes +app.use('/', viabilidadeRoutes); + +// Health endpoint +app.get('/health', (req, res) => res.json({ ok: true })); + +// 404 +app.use((req, res) => res.status(404).json({ error: 'Not found' })); + +// Error handler +app.use((err, req, res, next) => { + console.error(err && (err.stack || err.message) || err); + res.status(500).json({ error: 'Internal server error' }); +}); + +if (require.main === module) { + const port = parseInt(process.env.PORT, 10) || 3000; + app.listen(port, () => { + console.log(`Server listening on port ${port} (env=${process.env.NODE_ENV || 'production'})`); + if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { + console.log('[START-NOAUTH] DEV_SKIP_AUTH=true — authentication is bypassed'); + } + }); +} + +module.exports = app; \ No newline at end of file diff --git a/app.old.js b/app.old.js new file mode 100644 index 0000000..bf87131 --- /dev/null +++ b/app.old.js @@ -0,0 +1,681 @@ +// require("dotenv").config(); + +// 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 session = require("express-session"); // adiciona session +// const { geocodeWithGoogle, addressWithGoogle } = require("./service/geocodeService"); +// const { fetchJson } = require("./service/fetchService"); +// const { BASE_BACKOFF_MS, MAX_RETRIES, REQUEST_DELAY_MS, sleep } = require("./service/retryService"); +// const { API_URL, HEADERS } = require("./config/apiConfig"); +// const { normalizePartnerSigla } = require("./service/normalizeService"); +// const authRoutes = require("./routes/authRoutes.js"); + +// function createApp() { +// const upload = multer({ dest: "uploads/" }); +// const app = express(); + +// // se estiver atrás de um reverse proxy (nginx/traefik) em produção, habilite: +// app.set("trust proxy", 1); + +// app.use(cors()); +// app.use(express.json()); + +// // session TEM que vir antes das rotas que usam req.session +// app.use( +// session({ +// secret: process.env.SESSION_SECRET || "change-me", +// resave: false, +// saveUninitialized: false, +// cookie: { +// maxAge: 24 * 60 * 60 * 1000, +// secure: process.env.NODE_ENV === "production", +// sameSite: "lax", +// }, +// }) +// ); + +// // -- Desenvolvimento: pular autenticação se configurado +// // Para ativar: defina NODE_ENV=development e DEV_SKIP_AUTH=true no .env +// if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { +// app.use((req, res, next) => { +// // garante que exista uma sessão autenticada para facilitar testes locais +// if (req.session && (!req.session.user || !req.session.user.authenticated)) { +// req.session.user = { authenticated: true, dev: true }; +// } +// next(); +// }); +// } + +// // middleware que protege rotas que exigem login +// function requireAuth(req, res, next) { +// if (req.session?.user?.authenticated) { +// return next(); +// } + +// if (req.xhr || req.headers.accept?.includes("application/json")) { +// return res.status(401).json({ error: "not_authenticated" }); +// } + +// return res.redirect("/login"); +// } + + +// // proteger demais rotas (ex.: /upload, /consulta) +// app.use((req, res, next) => { +// // permissão liberada para rotas de auth já tratadas; proteger o resto +// if (req.path.startsWith("/auth") || req.path === "/login") return next(); +// return requireAuth(req, res, next); +// }); + +// // redirect raiz +// app.get("/", (req, res) => { +// // em dev com bypass, sirva a página diretamente (sem redirect) +// if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { +// return res.sendFile(path.join(__dirname, 'public', 'index.html')); +// } + +// if (req.session?.user?.authenticated) { +// return res.redirect("/public/index.html"); +// } +// return res.redirect("/login"); +// }); + +// ///////////////////////////////////////////////////// + +// 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) => { +// // ignora linhas totalmente vazias (todos os campos nulos/undefined/strings vazias) +// const values = Object.values(data); +// const allEmpty = +// values.length === 0 || +// values.every( +// (v) => v === null || v === undefined || String(v).trim() === "" +// ); +// if (!allEmpty) 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 = ""; + +// const googleGeocodeAddress = await addressWithGoogle(lat, lon); +// if (googleGeocodeAddress) { +// builtAddress = googleGeocodeAddress; +// } else { +// console.warn( +// `Google Reverse Geocoding não retornou resultado para coords ${lat},${lon} (row ${i + 1})` +// ); +// } + +// console.log(`Row ${i + 1}: CEP='${rawCep}' Número='${rawNumero}' Lat='${lat}' Lon='${lon}' BuiltAddress='${builtAddress}'`); + +// // If no coords, try ViaCEP -> Google +// if (!Number.isFinite(lat) || !Number.isFinite(lon)) { +// if (rawCep) { +// const cep8 = rawCep.padStart(8, "0"); +// const cepRestData = await fetch( +// 'https://api.cep.rest/', { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ cep: cep8 }) +// } +// ).then(r => r.json()); +// if (cepRestData && !cepRestData.erro) { +// const logradouro = cepRestData.data.logradouro || ""; +// const bairro = cepRestData.data.bairro || ""; +// const cidade = cepRestData.data.localidade || ""; +// const uf = cepRestData.data.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 model endpoint +// app.get("/download-model/:name", (req, res) => { +// const name = req.params.name; +// const safeName = path.basename(name); // evita ../ +// const filePath = path.join(__dirname, "models", safeName); +// if (!fs.existsSync(filePath)) return res.status(404).send("Arquivo não encontrado"); +// return res.download(filePath, safeName); +// }); + +// // lista os arquivos disponíveis em /models +// app.get("/download-models/list", (req, res) => { +// const modelDir = path.join(__dirname, "models"); +// if (!fs.existsSync(modelDir)) return res.status(404).json({ error: "Pasta de modelos não encontrada" }); +// const files = fs.readdirSync(modelDir).filter((f) => fs.statSync(path.join(modelDir, f)).isFile()); +// return res.json({ files }); +// }); + + + +// // download result 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 fetch( +// 'https://api.cep.rest/', { method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ cep }) +// } +// ).then(r => r.json()); +// if (!viaCepData || viaCepData.erro) +// return res.status(404).json({ error: "CEP não encontrado" }); +// const logradouro = viaCepData.data.logradouro || ""; +// const bairro = viaCepData.data.bairro || ""; +// const cidade = viaCepData.data.localidade || ""; +// const uf = viaCepData.data.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 cepRestData = await fetch( +// 'https://api.cep.rest/', { +// method: 'POST', +// headers: { 'Content-Type': 'application/json' }, +// body: JSON.stringify({ cep }) +// } +// ).then(r => r.json()); +// if (!cepRestData || cepRestData.erro) +// return res.status(404).json({ error: "CEP não encontrado" }); +// const logradouro = cepRestData.data.logradouro || ""; +// const bairro = cepRestData.data.bairro || ""; +// const cidade = cepRestData.data.localidade || ""; +// const uf = cepRestData.data.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" }); +// } +// }); + +// //////////////////////////////////////////////////// + +// // Servir arquivos estáticos (index.html) +// // app.use("/public", express.static(path.join(__dirname, "public"))); + +// // Usa as rotas de autenticação +// app.use("/", authRoutes); + +// // servir arquivos estáticos da pasta public (rotas protegidas já são tratadas pelo middleware global) +// app.use(express.static(path.join(__dirname, "public"))); + +// // rota protegida que serve o index.html +// app.get("/app", requireAuth, (req, res) => { +// res.sendFile(path.join(__dirname, "public", "index.html")); +// }); + + +// ///////////////////////////////////////////////////// + +// return app; +// } + +// module.exports = { +// createApp, +// }; diff --git a/config/apiConfig.js b/config/apiConfig.js index 788096b..ae6ca89 100644 --- a/config/apiConfig.js +++ b/config/apiConfig.js @@ -19,4 +19,7 @@ const apiConfig = { COOKIE, HEADERS }; -module.exports = apiConfig; \ No newline at end of file + +const apiViabilidadeUrl = process.env.API_VIABILIDADE_URL; + +module.exports = { apiConfig, apiViabilidadeUrl }; \ No newline at end of file diff --git a/controller/viabilidadeController.js b/controller/viabilidadeController.js new file mode 100644 index 0000000..f20b125 --- /dev/null +++ b/controller/viabilidadeController.js @@ -0,0 +1,54 @@ +const { consultarViabilidade } = require('../service/viabilidadeService'); +const { processCsvFile } = require('../service/csvService'); +const { getJob } = require('../service/jobStore.service'); + + +// Controlador para consultar viabilidade +async function consultarViabilidadeController(req, res) { + try { + const data = req.body; + const result = await consultarViabilidade(data); + res.json(result); + } catch (error) { + console.error("Erro ao consultar viabilidade:", error && (error.message || error)); + res.status(500).json({ error: "Erro ao consultar viabilidade" }); + } +} + +async function uploadCsvFile(req, res) { + try { + // validação simples: verifica se multer populou req.file + if (!req.file) { + return res.status(400).json({ error: 'Nenhum arquivo enviado. Campo do form deve ser "csvfile".' }); + } + + const filePath = req.file.path; + const originalName = req.file.originalname || req.file.filename || 'input.csv'; + + const out = await processCsvFile(filePath, originalName); + + // normaliza retorno (processCsvFile pode retornar string ou objeto) + const outputPath = (typeof out === 'string') ? out : (out && out.outputPath) || null; + + return res.json({ outputPath }); + } catch (error) { + console.error("Erro ao processar CSV:", error && (error.message || error)); + return res.status(500).json({ error: 'Erro ao processar CSV' }); + } +} + +async function getJobController(req, res) { + try { + const jobId = req.params.jobId; + const job = getJob(jobId); + if (!job) { + return res.status(404).json({ error: 'Job não encontrado' }); + } + res.json(job); + } catch (error) { + console.error("Erro ao obter job:", error && (error.message || error)); + return res.status(500).json({ error: 'Erro ao obter job' }); + } +} + +module.exports = { consultarViabilidadeController, uploadCsvFile, getJobController }; \ No newline at end of file diff --git a/public/main.js b/public/main.js index e6a4b4e..c3c43c2 100644 --- a/public/main.js +++ b/public/main.js @@ -56,15 +56,31 @@ document.getElementById('btnConsultaCep').addEventListener('click', async () => const el = document.getElementById('consultaResult'); el.innerText = 'Consultando...'; try { - const resp = await fetch(`/consulta-cep?cep=${encodeURIComponent(cep)}&numero=${encodeURIComponent(numero)}`); + // usar fetch ao invés de axios para evitar dependência no cliente + const resp = await fetch('/viabilidade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cep, numero }) + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new Error(`Status ${resp.status} ${text}`); + } + const data = await resp.json(); + if (data.distancia) { - el.innerText = `Endereço: ${data.endereco}\nLat: ${data.latitude} Lon: ${data.longitude}\nDistância: ${data.distancia}`; + el.innerText = `Endereço: ${data.logradouro}, ${data.bairro}, ${data.cidade}/${data.estado}, ${data.cep}\nDistância: ${data.distancia}`; } else if (data.error) { el.innerText = 'Erro: ' + data.error; + } else { + el.innerText = 'Resposta inesperada da API'; + console.log('Resposta completa:', data); } } catch (e) { - el.innerText = 'Erro na consulta'; + console.error(e); + el.innerText = 'Erro na consulta: ' + (e.message || e); } }); diff --git a/routes/viabilidadeRoutes.js b/routes/viabilidadeRoutes.js new file mode 100644 index 0000000..f50ccd0 --- /dev/null +++ b/routes/viabilidadeRoutes.js @@ -0,0 +1,24 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const multer = require('multer'); +const viabilidadeController = require('../controller/viabilidadeController'); + +// grava uploads temporários em /uploads +const upload = multer({ dest: path.join(__dirname, '..', 'uploads') }); + +// Rota para consultar viabilidade +router.post('/viabilidade', viabilidadeController.consultarViabilidadeController); + +// rota de upload agora usa multer.single('csvfile') +router.post('/upload', upload.single('csvfile'), viabilidadeController.uploadCsvFile); + +router.get('/status/:jobId', (req, res) => { + const job = viabilidadeController.getJobController(req.params.jobId); + if (!job) { + return res.status(404).json({ error: 'Job não encontrado' }); + } + res.json(job); +}); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 37bb160..2df99bc 100644 --- a/server.js +++ b/server.js @@ -1,17 +1,31 @@ -require('dotenv').config(); -const { createApp } = require('./app'); +const http = require('http'); +const app = require('./app'); -const app = createApp(); -// garante fallback caso Plesk não exponha PORT -const port = process.env.PORT || 3000; +const port = parseInt(process.env.PORT, 10) || 3000; +const server = http.createServer(app); -app.listen(port, () => { - console.log(`Server running on http://localhost:${port} (NODE_ENV=${process.env.NODE_ENV || 'development'})`); +server.listen(port, () => { + console.log(`Server listening on port ${port} (env=${process.env.NODE_ENV || 'production'})`); + if (process.env.NODE_ENV === 'development' && process.env.DEV_SKIP_AUTH === 'true') { + console.log('[START-NOAUTH] DEV_SKIP_AUTH=true — authentication is bypassed'); + } }); -process.on('uncaughtException', err => { - console.error('uncaughtException', err); -}); -process.on('unhandledRejection', err => { - console.error('unhandledRejection', err); -}); +function shutdown(signal) { + console.log(`Received ${signal}, shutting down...`); + server.close(() => { + console.log('Server closed.'); + process.exit(0); + }); + // force exit after 10s + setTimeout(() => { + console.error('Forcing shutdown.'); + process.exit(1); + }, 10000).unref(); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// export for tests / scripts +module.exports = server; \ No newline at end of file diff --git a/service/csvService.js b/service/csvService.js new file mode 100644 index 0000000..64c753f --- /dev/null +++ b/service/csvService.js @@ -0,0 +1,145 @@ +const { consultarViabilidade } = require('./viabilidadeService'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const { once } = require('events'); +const { + createJob, + incrementProcessed, + incrementErrors, + finishJob, + failJob +} = require('./jobStore.service'); + +// conta linhas válidas no CSV (com CEP e Número) +async function countValidLines(inputPath) { + const instream = fs.createReadStream(inputPath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: instream, crlfDelay: Infinity }); + + let isHeader = true; + let headers = []; + let idxCep = -1; + let idxNumero = -1; + let total = 0; + + for await (const rawLine of rl) { + const line = rawLine.replace(/\r$/, ''); + if (!line.trim()) continue; + + if (isHeader) { + headers = line.split(';').map(h => h.trim()); + const lower = headers.map(h => h.toLowerCase()); + idxCep = lower.indexOf('cep'); + idxNumero = lower.indexOf('numero'); + isHeader = false; + continue; + } + + const cols = line.split(';').map(c => c.trim()); + const cep = idxCep >= 0 ? String(cols[idxCep] || '').replace(/\D/g, '') : ''; + const numero = idxNumero >= 0 ? cols[idxNumero] : ''; + + if (cep && numero) total++; + } + + return total; +} + +// nova função: processa CSV linha a linha, chama consultarViabilidade e gera CSV de saída +async function processCsvFile(inputPath) { + const total = await countValidLines(inputPath); + const jobId = createJob(total); + const baseName = path.parse(inputPath).name; + const outputFilename = `processed_${Date.now()}_${baseName}.csv`; + const outputPath = path.join(__dirname, '..', 'outputs', outputFilename); + + const instream = fs.createReadStream(inputPath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: instream, crlfDelay: Infinity }); + const outStream = fs.createWriteStream(outputPath, { encoding: 'utf8' }); + outStream.write('\uFEFF'); + + let isHeader = true; + let headers = []; + let idxCep = -1; + let idxNumero = -1; + + for await (const rawLine of rl) { + const line = rawLine.replace(/\r$/, ''); // normalize CRLF + if (!line.trim()) continue; + + if (isHeader) { + headers = line + .split(';') + .map(h => h.trim()) + .filter(h => h !== ''); + + const lower = headers.map(h => h.toLowerCase()); + idxCep = lower.indexOf('cep'); + idxNumero = lower.indexOf('numero'); + + // se não encontrar, tenta variações comuns + const idx = lower.indexOf('codigo postal'); + if (idx !== -1) idxCep = idx; + + + const outHeaders = [...headers, 'Distancia', 'Endereco', 'Não Dedicado', 'Dedicado', 'Erro']; + outStream.write(outHeaders.join(';') + '\n'); + isHeader = false; + continue; + } + + const cols = line + .split(';') + .map(c => c.trim()) + .filter(c => c !== ''); + + const cepRaw = (idxCep >= 0 && cols[idxCep]) ? cols[idxCep] : ''; + const cep = String(cepRaw).replace(/\D/g, ''); // keep digits only + const numero = (idxNumero >= 0 && cols[idxNumero]) ? cols[idxNumero] : ''; + + if (!cep || !numero) { + continue; // pula linha inválida + } + + try { + const viab = await consultarViabilidade({ cep, numero }); + + const distancia = viab.distancia ?? (viab.raw && (viab.raw.distancia || viab.raw.distance)) ?? ''; + + const endereco = `${(viab.logradouro) || ''}, ${(viab.bairro) || ''}, ${(viab.cidade) || ''}/${(viab.estado) || ''}, ${(viab.cep) || ''}`.trim().replace(/^[, ]+|[, ]+$/g, ''); + + if (viab.naoDedicado) { + var naoDedicado = "Viavel"; + } else { + var naoDedicado = "Não Viavel"; + } + + if (viab.dedicado) { + var dedicado = "Viavel"; + } else { + var dedicado = "Não Viavel"; + } + + const error = viab.error ? String(viab.error).replace(/[\r\n;]/g, ' ') : ''; + + const outCols = [...cols, distancia, endereco, naoDedicado, dedicado, error]; + outStream.write(outCols.join(';') + '\n'); + incrementProcessed(jobId); + } catch (err) { + const errMsg = (err && (err.message || String(err))).replace(/[\r\n;]/g, ' '); + const outCols = [...cols, '', '', '', '', '', '', errMsg]; + outStream.write(outCols.join(';') + '\n'); + incrementErrors(jobId); + incrementProcessed(jobId); + } + } + + outStream.end(); + await once(outStream, 'finish'); + + finishJob(jobId, `/download/${path.basename(outputPath)}`); + + return { jobId, outputPath }; +} + +module.exports = { processCsvFile }; \ No newline at end of file diff --git a/service/fetchService.js b/service/fetchService.js deleted file mode 100644 index 0880146..0000000 --- a/service/fetchService.js +++ /dev/null @@ -1,14 +0,0 @@ -const axios = require('axios'); - -// 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; - } - } - -module.exports = { fetchJson }; \ No newline at end of file diff --git a/service/geocodeService.js b/service/geocodeService.js deleted file mode 100644 index 0796ede..0000000 --- a/service/geocodeService.js +++ /dev/null @@ -1,42 +0,0 @@ -const dotenv = require('dotenv'); -const axios = require('axios'); -dotenv.config(); - -// 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 addressWithGoogle(lat, lon) { - const key = process.env.GOOGLE_API_KEY; - if (!key) return null; - try { - const url = `https://maps.googleapis.com/maps/api/geocode/json?latlng=${encodeURIComponent(lat)},${encodeURIComponent(lon)}&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) { - console.log(`Google Reverse Geocoding result for ${lat},${lon}: ${r.data.results[0].formatted_address}`); - return r.data.results[0].formatted_address || null; - } - return null; - } catch (e) { - console.warn(`addressWithGoogle error for '${lat},${lon}': ${e.message}`); - return null; - } - } - -module.exports = { geocodeWithGoogle, addressWithGoogle }; \ No newline at end of file diff --git a/service/jobStore.service.js b/service/jobStore.service.js new file mode 100644 index 0000000..6cbaa31 --- /dev/null +++ b/service/jobStore.service.js @@ -0,0 +1,57 @@ +const crypto = require('crypto'); + +const jobs = new Map(); + +function createJob(total) { + const id = crypto.randomUUID(); + jobs.set(id, { + id, + status: 'processing', + total, + processed: 0, + errors: 0, + download: null, + startedAt: new Date() + }); + return id; +} + +function incrementProcessed(id) { + const job = jobs.get(id); + if (job) job.processed++; +} + +function incrementErrors(id) { + const job = jobs.get(id); + if (job) job.errors++; +} + +function finishJob(id, download) { + const job = jobs.get(id); + if (job) { + job.status = 'done'; + job.download = download; + job.finishedAt = new Date(); + } +} + +function failJob(id, error) { + const job = jobs.get(id); + if (job) { + job.status = 'error'; + job.error = error; + } +} + +function getJob(id) { + return jobs.get(id); +} + +module.exports = { + createJob, + incrementProcessed, + incrementErrors, + finishJob, + failJob, + getJob +}; diff --git a/service/normalizeService.js b/service/normalizeService.js deleted file mode 100644 index dabe994..0000000 --- a/service/normalizeService.js +++ /dev/null @@ -1,13 +0,0 @@ -// 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; - } - -module.exports = { normalizePartnerSigla }; \ No newline at end of file diff --git a/service/retryService.js b/service/retryService.js deleted file mode 100644 index 45981d6..0000000 --- a/service/retryService.js +++ /dev/null @@ -1,18 +0,0 @@ -const dotenv = require('dotenv'); - -dotenv.config(); - -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)); -} - -module.exports = { - BASE_BACKOFF_MS, - MAX_RETRIES, - REQUEST_DELAY_MS, - sleep -}; \ No newline at end of file diff --git a/service/viabilidadeService.js b/service/viabilidadeService.js new file mode 100644 index 0000000..8c3ab72 --- /dev/null +++ b/service/viabilidadeService.js @@ -0,0 +1,55 @@ +const axios = require('axios'); +const { apiConfig, apiViabilidadeUrl } = require('../config/apiConfig'); + +const DEFAULT_TIMEOUT = (apiConfig && apiConfig.timeoutMs) || 10000; +const MAX_RETRIES = (apiConfig && apiConfig.maxRetries) || 3; +const BASE_BACKOFF_MS = (apiConfig && apiConfig.baseBackoffMs) || 500; + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function normalizeResponse(body) { + if (!body) return { error: 'Resposta vazia', raw: body }; + // Caso já venha com distancia/distance direto + if (typeof body === 'object') { + if (body.distancia || body.distance) { + return { + distancia: body.distancia || body.distance, + raw: body + }; + } + // formato antigo com array registros + if (Array.isArray(body.registros) && body.registros.length) { + // seleciona o registro com menor distancia (se existir) + let min = null; + for (const r of body.registros) { + const d = r.distancia ?? r.distance ?? null; + if (d == null) continue; + if (min == null || d < min.d) min = { d, r }; + } + return { + distancia: min ? min.d : null, + parceiro: min ? (min.r.pastaSigla || min.r.parceiro || null) : null, + raw: body + }; + } + // fallback: retorna body bruto para inspeção + return { raw: body }; + } + return { raw: body }; +} + +async function consultarViabilidade(data) { + try { + const response = await axios.post(apiViabilidadeUrl, data, { + timeout: DEFAULT_TIMEOUT, + headers: { 'Content-Type': 'application/json' } + }); + return (response.data); + } catch (error) { + throw error; + } +} + +module.exports = { consultarViabilidade }; \ No newline at end of file