From 503359ee973d4fb8838a207385f2791ab0b1d25d Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sat, 13 Jun 2026 23:18:49 +0200 Subject: [PATCH] feat(geo): busca por raio (PostGIS) em Salas e Profissionais MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - geocoder de CEP com cache (cep_geo) + rotação: AwesomeAPI → Nominatim(CEP, c/ User-Agent) → ViaCEP+Nominatim(endereço) - colunas geo geography(Point,4326) + índice GiST em salas e usuarios - geocoda ao salvar (POST/PUT salas, PUT perfil) via setGeoByCep - marketplaces aceitam ?cep=&raio= → ST_DWithin + dist_km, ordenado por distância - frontend: filtro Meu CEP + Raio + badge ~X KM nos cards - fix: clinicas.nome inexistente → nome_fantasia (salas/minhas + marketplace) Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 131 ++++++++++++++++-- .../views/plugins/ProfissionaisPlugin.tsx | 14 +- frontend/views/plugins/SalasPlugin.tsx | 17 ++- 3 files changed, 148 insertions(+), 14 deletions(-) diff --git a/backend/server.js b/backend/server.js index 5cc3bdd..fded115 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3997,6 +3997,21 @@ async function runMigrations() { ativado_em TIMESTAMPTZ DEFAULT NOW(), PRIMARY KEY (usuario_id, plugin_id) )`, + // ── Geolocalização (PostGIS já instalado na VPS3) ──────────────────── + // Cache de CEP → coordenadas (geocoda cada CEP uma vez; economia + multi-provedor). + `CREATE TABLE IF NOT EXISTS cep_geo ( + cep TEXT PRIMARY KEY, + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + fonte TEXT, + precisao TEXT, + atualizado_em TIMESTAMPTZ DEFAULT NOW() + )`, + // Coluna geográfica (ponto WGS84) p/ busca por raio em salas e profissionais. + `ALTER TABLE salas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, + `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, + `CREATE INDEX IF NOT EXISTS idx_salas_geo ON salas USING GIST (geo)`, + `CREATE INDEX IF NOT EXISTS idx_usuarios_geo ON usuarios USING GIST (geo)`, // ── PLUGIN: Marketplace de Profissionais ───────────────────────────── `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`, @@ -4152,11 +4167,72 @@ app.post('/api/me/plugins', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// ── Geocoding de CEP: cache + rotação de provedores grátis ────────────────── +// Economia: cada CEP é geocodado UMA vez (cache cep_geo). Rotação robusta p/ o +// egress deste servidor: AwesomeAPI → Nominatim(CEP) → ViaCEP+Nominatim(endereço). +// Nominatim exige User-Agent identificável e é rate-limited (~1 req/s) — o cache +// torna as chamadas raras. Retorna null se ninguém resolver (não bloqueia o save). +const GEO_UA = 'ScoreOdonto/1.0 (+https://scoreodonto.com)'; + +async function _geoAwesome(cep) { + try { + const r = await fetch(`https://cep.awesomeapi.com.br/json/${cep}`, { signal: AbortSignal.timeout(5000) }); + if (!r.ok) return null; + const d = await r.json(); + if (d?.lat && d?.lng) return { lat: +d.lat, lng: +d.lng, fonte: 'awesomeapi', precisao: 'exata' }; + return null; + } catch { return null; } +} +async function _geoNominatim(query, fonte, precisao) { + try { + const r = await fetch(`https://nominatim.openstreetmap.org/search?format=json&limit=1&${query}`, + { headers: { 'User-Agent': GEO_UA, 'Accept-Language': 'pt-BR' }, signal: AbortSignal.timeout(8000) }); + if (!r.ok) return null; + const d = await r.json(); + if (Array.isArray(d) && d[0]?.lat && d[0]?.lon) return { lat: +d[0].lat, lng: +d[0].lon, fonte, precisao }; + return null; + } catch { return null; } +} +async function _viaCepEndereco(cep) { + try { + const r = await fetch(`https://viacep.com.br/ws/${cep}/json/`, { signal: AbortSignal.timeout(5000) }); + if (!r.ok) return null; + const d = await r.json(); + if (d.erro) return null; + return [d.logradouro, d.bairro, d.localidade, d.uf, 'Brasil'].filter(Boolean).join(', '); + } catch { return null; } +} +async function geocodarCep(cep) { + const digits = (cep || '').replace(/\D/g, ''); + if (digits.length !== 8) return null; + const cached = await pool.query('SELECT lat, lng, fonte, precisao FROM cep_geo WHERE cep = $1', [digits]); + if (cached.rows.length) return cached.rows[0]; + + let r = await _geoAwesome(digits); + if (!r) r = await _geoNominatim(`country=Brazil&postalcode=${digits.slice(0, 5)}-${digits.slice(5)}`, 'nominatim-cep', 'exata'); + if (!r) { const addr = await _viaCepEndereco(digits); if (addr) r = await _geoNominatim(`q=${encodeURIComponent(addr)}`, 'nominatim-addr', 'aproximada'); } + if (!r) return null; + + await pool.query( + `INSERT INTO cep_geo (cep, lat, lng, fonte, precisao) VALUES ($1,$2,$3,$4,$5) + ON CONFLICT (cep) DO UPDATE SET lat=EXCLUDED.lat, lng=EXCLUDED.lng, fonte=EXCLUDED.fonte, precisao=EXCLUDED.precisao, atualizado_em=NOW()`, + [digits, r.lat, r.lng, r.fonte, r.precisao]); + return r; +} +// Atualiza a coluna geography de um registro pelo CEP (best-effort; `table` é fixo no servidor). +async function setGeoByCep(table, id, cep) { + try { + const g = await geocodarCep(cep); + if (g) await pool.query(`UPDATE ${table} SET geo = ST_SetSRID(ST_MakePoint($1,$2),4326)::geography WHERE id = $3`, [g.lng, g.lat, id]); + return g; + } catch (e) { console.error('[setGeoByCep]', table, e.message); return null; } +} + // Minhas salas (como locador) app.get('/api/salas/minhas', authGuard, async (req, res) => { try { const { rows } = await pool.query( - `SELECT s.*, c.nome AS clinica_nome, + `SELECT s.*, c.nome_fantasia AS clinica_nome, (SELECT COUNT(*)::int FROM sala_reservas r WHERE r.sala_id = s.id AND r.status = 'pendente') AS reservas_pendentes FROM salas s LEFT JOIN clinicas c ON c.id = s.clinica_id WHERE s.owner_usuario_id = $1 ORDER BY s.created_at DESC`, @@ -4179,6 +4255,7 @@ app.post('/api/salas', authGuard, async (req, res) => { b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null, b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null, b.disponivel !== false]); + await setGeoByCep('salas', id, b.cep); await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } }); res.json({ success: true, id }); } catch (err) { res.status(500).json({ error: err.message }); } @@ -4199,6 +4276,7 @@ app.put('/api/salas/:id', authGuard, async (req, res) => { if (!keys.length) return res.json({ success: true }); const set = keys.map((k, i) => `${k} = $${i + 1}`).join(', '); await pool.query(`UPDATE salas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(k => COLS[k]), req.params.id]); + if (b.cep !== undefined) await setGeoByCep('salas', req.params.id, b.cep); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -4221,18 +4299,34 @@ app.delete('/api/salas/:id', authGuard, async (req, res) => { // Marketplace de salas (qualquer usuário autenticado) app.get('/api/salas/marketplace', authGuard, async (req, res) => { try { - const { tipo, estado, cidade } = req.query; - let where = `WHERE s.disponivel = true`; + const { tipo, estado, cidade, cep, raio } = req.query; + // Busca por raio: CEP do buscador → coordenada (cache). Se geocodar falhar, cai no filtro normal. + const raioKm = Number(raio); + const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null; + const params = []; + let where = `WHERE s.disponivel = true`; if (tipo && SALA_TIPOS.includes(tipo)) { params.push(tipo); where += ` AND s.tipo = $${params.length}`; } if (estado) { params.push(String(estado).toUpperCase()); where += ` AND s.estado = $${params.length}`; } if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND s.cidade ILIKE $${params.length}`; } + + let distSelect = 'NULL::float AS dist_km'; + let order = 'ORDER BY s.estado, s.cidade, s.nome'; + if (origin) { + params.push(origin.lng); const pLng = params.length; + params.push(origin.lat); const pLat = params.length; + params.push(raioKm * 1000); const pRaio = params.length; + const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`; + distSelect = `ROUND((ST_Distance(s.geo, ${pt})/1000)::numeric, 1) AS dist_km`; + where += ` AND s.geo IS NOT NULL AND ST_DWithin(s.geo, ${pt}, $${pRaio})`; + order = `ORDER BY ST_Distance(s.geo, ${pt})`; + } const { rows } = await pool.query( `SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal, - s.owner_usuario_id, u.nome AS owner_nome, c.nome AS clinica_nome + s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect} FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id - ${where} ORDER BY s.estado, s.cidade, s.nome LIMIT 200`, params); + ${where} ${order} LIMIT 200`, params); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -4418,23 +4512,42 @@ app.put('/api/profissionais/perfil', authGuard, async (req, res) => { b.cep || null, b.especialidade?.toUpperCase() || b.especialidade_dir?.toUpperCase() || null, Number.isFinite(+b.raio_atuacao_km) && +b.raio_atuacao_km > 0 ? Math.min(+b.raio_atuacao_km, 2000) : null, b.bio_profissional || null, b.disponivel_diretorio === true, req.authUser.userId]); + await setGeoByCep('usuarios', req.authUser.userId, b.cep); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // Busca de profissionais (inclui biomédicos; supera o diretório antigo) app.get('/api/profissionais/marketplace', authGuard, async (req, res) => { - const { tipo, estado, cidade, especialidade, cep } = req.query; + const { tipo, estado, cidade, especialidade, cep, raio } = req.query; + const raioKm = Number(raio); + const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null; + let where = `WHERE disponivel_diretorio = true AND COALESCE(ativo, true) = true AND role = ANY($1::text[])`; const params = [tipo && MARKETPLACE_ROLES.includes(tipo) ? [tipo] : MARKETPLACE_ROLES]; if (estado) { params.push(String(estado).toUpperCase()); where += ` AND estado = $${params.length}`; } if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND cidade ILIKE $${params.length}`; } if (especialidade) { params.push(`%${String(especialidade).toUpperCase()}%`); where += ` AND especialidade_dir ILIKE $${params.length}`; } - if (cep) { params.push(`${String(cep).replace(/\D/g, '').slice(0, 5)}%`); where += ` AND regexp_replace(COALESCE(cep,''), '\\D', '', 'g') LIKE $${params.length}`; } + + let distSelect = 'NULL::float AS dist_km'; + let order = 'ORDER BY estado, cidade, nome'; + if (origin) { + params.push(origin.lng); const pLng = params.length; + params.push(origin.lat); const pLat = params.length; + params.push(raioKm * 1000); const pRaio = params.length; + const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`; + distSelect = `ROUND((ST_Distance(geo, ${pt})/1000)::numeric, 1) AS dist_km`; + where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${pRaio})`; + order = `ORDER BY ST_Distance(geo, ${pt})`; + } else if (cep) { + // Sem raio: mantém o filtro antigo por prefixo de CEP (compatível). + params.push(`${String(cep).replace(/\D/g, '').slice(0, 5)}%`); + where += ` AND regexp_replace(COALESCE(cep,''), '\\D', '', 'g') LIKE $${params.length}`; + } try { const { rows } = await pool.query( - `SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir AS especialidade, raio_atuacao_km, bio_profissional - FROM usuarios ${where} ORDER BY estado, cidade, nome LIMIT 200`, params); + `SELECT id, nome, email, celular, role, estado, cidade, bairro, cep, especialidade_dir AS especialidade, raio_atuacao_km, bio_profissional, ${distSelect} + FROM usuarios ${where} ${order} LIMIT 200`, params); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); diff --git a/frontend/views/plugins/ProfissionaisPlugin.tsx b/frontend/views/plugins/ProfissionaisPlugin.tsx index 5c4f631..d569c7c 100644 --- a/frontend/views/plugins/ProfissionaisPlugin.tsx +++ b/frontend/views/plugins/ProfissionaisPlugin.tsx @@ -37,6 +37,7 @@ interface Profissional { especialidade: string; raio_atuacao_km?: number | null; bio_profissional?: string | null; + dist_km?: number | string | null; } interface Proposta { @@ -138,6 +139,7 @@ export const ProfissionaisPlugin: React.FC = () => { const [fCidade, setFCidade] = useState(''); const [fEspecialidade, setFEspecialidade] = useState(''); const [fCep, setFCep] = useState(''); + const [fRaio, setFRaio] = useState(''); // perfil const [perfil, setPerfil] = useState(null); const [savingPerfil, setSavingPerfil] = useState(false); @@ -154,11 +156,12 @@ export const ProfissionaisPlugin: React.FC = () => { if (fCidade) params.set('cidade', fCidade); if (fEspecialidade) params.set('especialidade', fEspecialidade); if (fCep) params.set('cep', fCep); + if (fCep && Number(fRaio) > 0) params.set('raio', fRaio); const res = await apiFetch(`/api/profissionais/marketplace?${params}`); if (res.ok) setLista(await res.json()); } catch { /* silently fail */ } finally { setLoading(false); } - }, [fTipo, fEstado, fCidade, fEspecialidade, fCep]); + }, [fTipo, fEstado, fCidade, fEspecialidade, fCep, fRaio]); const fetchPerfil = useCallback(async () => { setLoading(true); @@ -288,8 +291,12 @@ export const ProfissionaisPlugin: React.FC = () => { setFEspecialidade(e.target.value.toUpperCase())} placeholder="ORTODONTIA" />
- - setFCep(e.target.value)} placeholder="79000" /> + + setFCep(e.target.value)} placeholder="79000-000" /> +
+
+ + setFRaio(e.target.value)} placeholder="20" />