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 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(', ');
|
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]);
|
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 });
|
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 }); }
|
} 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.
|
// 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 salas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
|
||||||
`ALTER TABLE usuarios 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_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_usuarios_geo ON usuarios USING GIST (geo)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_clinicas_geo ON clinicas USING GIST (geo)`,
|
||||||
// ── PLUGIN: Marketplace de Profissionais ─────────────────────────────
|
// ── PLUGIN: Marketplace de Profissionais ─────────────────────────────
|
||||||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`,
|
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`,
|
||||||
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`,
|
`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; }
|
} 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)
|
// Minhas salas (como locador)
|
||||||
app.get('/api/salas/minhas', authGuard, async (req, res) => {
|
app.get('/api/salas/minhas', authGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -4299,10 +4317,10 @@ app.delete('/api/salas/:id', authGuard, async (req, res) => {
|
|||||||
// Marketplace de salas (qualquer usuário autenticado)
|
// Marketplace de salas (qualquer usuário autenticado)
|
||||||
app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { tipo, estado, cidade, cep, raio } = req.query;
|
const { tipo, estado, cidade, cep, raio, clinicaId } = req.query;
|
||||||
// Busca por raio: CEP do buscador → coordenada (cache). Se geocodar falhar, cai no filtro normal.
|
|
||||||
const raioKm = Number(raio);
|
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 = [];
|
const params = [];
|
||||||
let where = `WHERE s.disponivel = true`;
|
let where = `WHERE s.disponivel = true`;
|
||||||
@@ -4315,11 +4333,10 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
|||||||
if (origin) {
|
if (origin) {
|
||||||
params.push(origin.lng); const pLng = params.length;
|
params.push(origin.lng); const pLng = params.length;
|
||||||
params.push(origin.lat); const pLat = 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`;
|
const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`;
|
||||||
distSelect = `ROUND((ST_Distance(s.geo, ${pt})/1000)::numeric, 1) AS dist_km`;
|
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}) NULLS LAST`;
|
||||||
order = `ORDER BY ST_Distance(s.geo, ${pt})`;
|
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(
|
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,
|
`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)
|
// Busca de profissionais (inclui biomédicos; supera o diretório antigo)
|
||||||
app.get('/api/profissionais/marketplace', authGuard, async (req, res) => {
|
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 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[])`;
|
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];
|
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) {
|
if (origin) {
|
||||||
params.push(origin.lng); const pLng = params.length;
|
params.push(origin.lng); const pLng = params.length;
|
||||||
params.push(origin.lat); const pLat = 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`;
|
const pt = `ST_SetSRID(ST_MakePoint($${pLng},$${pLat}),4326)::geography`;
|
||||||
distSelect = `ROUND((ST_Distance(geo, ${pt})/1000)::numeric, 1) AS dist_km`;
|
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}) NULLS LAST`;
|
||||||
order = `ORDER BY ST_Distance(geo, ${pt})`;
|
if (Number.isFinite(raioKm) && raioKm > 0) { params.push(raioKm * 1000); where += ` AND geo IS NOT NULL AND ST_DWithin(geo, ${pt}, $${params.length})`; }
|
||||||
} 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 {
|
try {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
|
|||||||
@@ -155,7 +155,10 @@ export const ProfissionaisPlugin: React.FC = () => {
|
|||||||
if (fEstado) params.set('estado', fEstado);
|
if (fEstado) params.set('estado', fEstado);
|
||||||
if (fCidade) params.set('cidade', fCidade);
|
if (fCidade) params.set('cidade', fCidade);
|
||||||
if (fEspecialidade) params.set('especialidade', fEspecialidade);
|
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);
|
if (fCep && Number(fRaio) > 0) params.set('raio', fRaio);
|
||||||
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
|
const res = await apiFetch(`/api/profissionais/marketplace?${params}`);
|
||||||
if (res.ok) setLista(await res.json());
|
if (res.ok) setLista(await res.json());
|
||||||
|
|||||||
@@ -467,7 +467,11 @@ export const SalasPlugin: React.FC = () => {
|
|||||||
if (fTipo) params.set('tipo', fTipo);
|
if (fTipo) params.set('tipo', fTipo);
|
||||||
if (fEstado) params.set('estado', fEstado);
|
if (fEstado) params.set('estado', fEstado);
|
||||||
if (fCidade) params.set('cidade', fCidade);
|
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}`);
|
const res = await apiFetch(`/api/salas/marketplace?${params}`);
|
||||||
if (res.ok) setSalas(await res.json());
|
if (res.ok) setSalas(await res.json());
|
||||||
} catch { /* silently fail */ }
|
} catch { /* silently fail */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user