feat(geo): origem automática por proximidade (clínica/usuário) nos marketplaces

- coluna geo em clinicas + índice GiST; geocoda ao salvar endereço da clínica
- helper resolveOrigem: CEP digitado → clínica ativa → geo do próprio usuário
- marketplaces (salas/profissionais) ordenam por distância sempre que há origem;
  raio passa a ser filtro opcional (não mais obrigatório p/ calcular distância)
- frontend envia clinicaId ativo automaticamente (CEP digitado sobrepõe)
- clínica buscando profissional: mais próximos + especialidade; dentista→sala por distância

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 01:27:48 +02:00
parent 503359ee97
commit fb7825f4c1
3 changed files with 37 additions and 17 deletions
+28 -15
View File
@@ -639,6 +639,7 @@ app.put('/api/clinicas/:clinicaId', authGuard, async (req, res) => {
const val = (c) => (UPPER.has(c) && typeof b[c] === 'string') ? b[c].trim().toUpperCase() : b[c];
const set = keys.map((c, i) => `${c} = $${i + 1}`).join(', ');
await pool.query(`UPDATE clinicas SET ${set} WHERE id = $${keys.length + 1}`, [...keys.map(val), req.params.clinicaId]);
if (b.cep !== undefined) await setGeoByCep('clinicas', req.params.clinicaId, b.cep);
res.json({ success: true, nome: b.nome_fantasia ? String(b.nome_fantasia).trim().toUpperCase() : undefined });
} catch (err) { res.status(500).json({ success: false, message: err.message }); }
});
@@ -4010,8 +4011,10 @@ async function runMigrations() {
// 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)`,
`ALTER TABLE clinicas 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)`,
`CREATE INDEX IF NOT EXISTS idx_clinicas_geo ON clinicas 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`,
@@ -4228,6 +4231,21 @@ async function setGeoByCep(table, id, cep) {
} catch (e) { console.error('[setGeoByCep]', table, e.message); return null; }
}
// Resolve a ORIGEM da busca (de onde medir distância): CEP digitado → clínica ativa
// (geo da clínica) → fallback no geo do próprio usuário logado. Retorna {lat,lng} ou null.
async function resolveOrigem(req, cep, clinicaId) {
if (cep) return await geocodarCep(cep);
try {
if (clinicaId) {
const r = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM clinicas WHERE id=$1 AND geo IS NOT NULL', [clinicaId]);
if (r.rows.length) return r.rows[0];
}
const u = await pool.query('SELECT ST_Y(geo::geometry) AS lat, ST_X(geo::geometry) AS lng FROM usuarios WHERE id=$1 AND geo IS NOT NULL', [req.authUser.userId]);
if (u.rows.length) return u.rows[0];
} catch (e) { console.error('[resolveOrigem]', e.message); }
return null;
}
// Minhas salas (como locador)
app.get('/api/salas/minhas', authGuard, async (req, res) => {
try {
@@ -4299,10 +4317,10 @@ 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, cep, raio } = req.query;
// Busca por raio: CEP do buscador → coordenada (cache). Se geocodar falhar, cai no filtro normal.
const { tipo, estado, cidade, cep, raio, clinicaId } = req.query;
const raioKm = Number(raio);
const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null;
// Origem = CEP digitado → clínica ativa → próprio usuário (proximidade automática).
const origin = await resolveOrigem(req, cep, clinicaId);
const params = [];
let where = `WHERE s.disponivel = true`;
@@ -4315,11 +4333,10 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
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})`;
order = `ORDER BY ST_Distance(s.geo, ${pt}) NULLS LAST`;
if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND s.geo IS NOT NULL AND ST_DWithin(s.geo, ${pt}, $${params.length})`; }
}
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,
@@ -4519,9 +4536,10 @@ app.put('/api/profissionais/perfil', authGuard, async (req, res) => {
// 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, raio } = req.query;
const { tipo, estado, cidade, especialidade, cep, raio, clinicaId } = req.query;
const raioKm = Number(raio);
const origin = (cep && Number.isFinite(raioKm) && raioKm > 0) ? await geocodarCep(cep) : null;
// Origem = CEP digitado → clínica ativa → próprio usuário (mostra os mais próximos primeiro).
const origin = await resolveOrigem(req, cep, clinicaId);
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];
@@ -4534,15 +4552,10 @@ app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
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}`;
order = `ORDER BY ST_Distance(geo, ${pt}) NULLS LAST`;
if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${params.length})`; }
}
try {
const { rows } = await pool.query(
@@ -155,7 +155,10 @@ export const ProfissionaisPlugin: React.FC = () => {
if (fEstado) params.set('estado', fEstado);
if (fCidade) params.set('cidade', fCidade);
if (fEspecialidade) params.set('especialidade', fEspecialidade);
if (fCep) params.set('cep', fCep);
// Origem automática: clínica ativa (backend cai no geo do usuário se faltar) → mais próximos primeiro.
const wsId = HybridBackend.getActiveWorkspace()?.id;
if (wsId) params.set('clinicaId', wsId);
if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
if (res.ok) setLista(await res.json());
+5 -1
View File
@@ -467,7 +467,11 @@ export const SalasPlugin: React.FC = () => {
if (fTipo) params.set('tipo', fTipo);
if (fEstado) params.set('estado', fEstado);
if (fCidade) params.set('cidade', fCidade);
if (fCep && Number(fRaio) > 0) { params.set('cep', fCep); params.set('raio', fRaio); }
// Origem automática: clínica/consultório ativo (backend cai no geo do usuário se faltar).
const wsId = HybridBackend.getActiveWorkspace()?.id;
if (wsId) params.set('clinicaId', wsId);
if (fCep) params.set('cep', fCep); // CEP digitado sobrepõe a origem automática
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
const res = await apiFetch(`/api/salas/marketplace?${params}`);
if (res.ok) setSalas(await res.json());
} catch { /* silently fail */ }