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:
+28
-15
@@ -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());
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user