Skip to content

feat: hospitais do SUS - peçonhentos, oncologia e doenças raras - #891

Open
felipeflfranca wants to merge 12 commits into
BrasilAPI:mainfrom
felipeflfranca:feat/hospitais-mapasus
Open

feat: hospitais do SUS - peçonhentos, oncologia e doenças raras#891
felipeflfranca wants to merge 12 commits into
BrasilAPI:mainfrom
felipeflfranca:feat/hospitais-mapasus

Conversation

@felipeflfranca

Copy link
Copy Markdown
Contributor

📋 Descrição

Novo domínio hospitais: expõe os hospitais de referência habilitados pelo SUS em três verticais — animais peçonhentos (soros antiveneno), oncologia (habilitações CGCAN) e doenças raras (habilitações SRDR/SAE) — mais os 32 centros CIATOX de assistência toxicológica. São 2.342 estabelecimentos, 99,7% geocodificados.

Esses dados não existem hoje em nenhuma API pública: as fontes originais são PDFs estaduais e planilhas XLSX no gov.br/saude. A utilidade é direta — achar o hospital com o soro antiveneno certo é um problema com janela de minutos.

Rotas

Rota Para quê
GET /api/hospitais/v1 Pesquisa com filtros e paginação
GET /api/hospitais/v1/proximos Busca por raio, ordenada por distância (origem por CEP, coordenadas ou município+UF)
GET /api/hospitais/v1/opcoes Vocabulário aceito, com contagem — para montar seletores
GET /api/hospitais/v1/ciatox Centros de toxicologia (a orientação oficial é ligar antes de se deslocar)

Busca unificada

?atendimento= atravessa as três verticais e aceita nome popular do animal (cascavel, jararaca, escorpiao), soro em PT ou EN (crotalico/Crotalic), habilitação (radioterapia, cacon, terapia-genica) ou código de portaria (17.07, 35.16). Acento, caixa, hífen e underscore são equivalentes. Termo desconhecido retorna 400 com a lista de valores aceitos — lista vazia esconderia um typo atrás de "nenhum hospital encontrado", o que num endpoint de saúde é pior que um erro explícito.

Arquitetura: snapshot, não proxy

A fonte dos dados é o MapaSUS (Codar-Sistemas/hospitais-referencia-api, MIT), que faz scraping diário dos documentos do MS, extrai (incl. OCR com verificação de confiança), normaliza e geocodifica.

Nenhuma rota chama o MapaSUS em request-time. Ele roda 100% em free tier (Supabase + Vercel Hobby, 15 req/min por IP); a BrasilAPI serve de lambdas efêmeras sem estado compartilhado, então sob pico cada cold start viraria uma rajada no serviço. Em vez disso, o dataset é servido de services/hospitais/snapshots/latest.json (1,4 MB — menor que o ncmList.json de 2,85 MB já versionado), atualizado por GitHub Action diário que faz ~83 requests serializadas com pausa de 5s, de um único IP. Guardrail de promoção: o snapshot só é sobrescrito se a coleta trouxer ≥80% dos registros da anterior — um sync quebrado na fonte não zera o dataset. Mesmo padrão do snapshot de sedes de bancos (banks-headquarters-snapshot.yml), com uma correção: o workflow commita apenas latest.json e metrics-latest.json, sem os arquivos datados por run.

Procedência e segurança

Toda resposta traz dois objetos fixos:

  • fonte — com oficial: false e aviso explícito de que nem o MapaSUS nem a BrasilAPI são serviços oficiais do Ministério da Saúde; os dados vêm de documentos públicos do órgão, extraídos automaticamente.
  • emergencia — SAMU 192, Disque-Intoxicação 0800 722 6001 e a orientação de ligar ao CIATOX antes de se deslocar (a unidade listada pode estar sem o soro). Dois testes E2E garantem que nenhuma rota responde sem esses avisos.

O campo requires_verification marca registros extraídos por OCR com confiança baixa.

🎯 Tipo de Mudança

  • 🐛 Correção de bug (mudança que corrige um problema)
  • ✨ Nova funcionalidade (mudança que adiciona funcionalidade)
  • 💥 Breaking change (correção ou funcionalidade que causa quebra de compatibilidade)
  • 📝 Documentação (mudanças apenas em documentação)
  • ♻️ Refatoração (mudança que não corrige bug nem adiciona funcionalidade)
  • ⚡ Performance (mudança que melhora performance)
  • ✅ Testes (adiciona ou corrige testes)
  • 🔧 Configuração (mudanças em configuração ou build)

⚠️ Checklist de Compatibilidade (CRÍTICO)

  • ✅ Não remove campos de respostas de API existentes
  • ✅ Não renomeia campos de respostas de API existentes
  • ✅ Não muda tipos de dados de campos existentes (string → number, etc.)
  • ✅ Não muda o formato de URLs de endpoints existentes
  • ✅ Não muda códigos de status HTTP de endpoints existentes
  • ✅ Se fez mudanças incompatíveis, criei uma nova versão (v2, v3, etc.)

Mudança 100% aditiva. Os únicos arquivos existentes tocados foram package.json (script novo snapshot:hospitais) e pages/docs/doc/basic_info.json (uma linha: HOSPITAIS no grupo Saúde).

📚 Checklist de Documentação

  • ✅ Atualizei ou criei documentação OpenAPI em /pages/docs/doc/
  • ✅ Documentação inclui exemplos de requisição e resposta
  • ✅ Documentação está em português
  • Atualizei README.md se necessário — não foi necessário; a doc do domínio vive no OpenAPI e em docs/HOSPITAIS_MAPASUS.md
  • ✅ N/A - Mudanças não requerem documentação

Todos os exemplos da doc foram executados contra o servidor antes de serem documentados.

🧪 Checklist de Testes

  • ✅ Criei ou atualizei testes E2E
  • ✅ Todos os testes passam localmente (npm test)
  • ✅ Teste de CORS funciona corretamente
  • ✅ Testei casos de erro (404, 400, 500, etc.)
  • ✅ Testei casos de sucesso
  • ✅ N/A - Mudanças não requerem testes

60 testes: 46 E2E (tests/hospitais-v1.test.js) + 11 de unidade para o vocabulário (extração de códigos sobre texto sujo de OCR, aliases, equivalência de grafias) + 3 para o haversine. Falhas pré-existentes em cambio/fipe/diasuteis/pix foram reproduzidas idênticas na main limpa (provedores externos instáveis) — não relacionadas a este PR.

💻 Checklist de Código

  • ✅ Código segue os padrões do projeto (ESLint + Prettier)
  • ✅ Executei npm run fix antes de commitar
  • ✅ Não adicionei dependências desnecessárias ou pesadas
  • ✅ Código não expõe credenciais ou informações sensíveis
  • ✅ Validei todos os inputs de usuário
  • ✅ Tratei erros apropriadamente
  • ✅ Usei Conventional Commits

Zero dependências novas — até o haversine foi escrito à mão (util/haversine.js, 19 linhas). Camadas numa direção só: vocabulario.js (puro, zero deps) ← query.jsorigem.js; index.js é o único módulo que conhece o snapshot.

🚀 Checklist de Performance e Custos

  • ✅ Não adicionei processamento pesado que aumenta custos
  • ✅ Usei cache quando apropriado
  • ✅ Minimizei chamadas a APIs externas
  • ✅ Considerei impacto em rate limits de APIs externas
  • ✅ Testei performance em casos de alto volume

Request-time: filtro em memória sobre import estático (1–8ms medidos localmente, sem chamadas de rede no caminho quente), app({ cache: 86400 }). Únicas chamadas externas em request-time: geocoding da origem no /proximos (reusa lib/fetchGeocoordinateFromBrazilLocation.js, timeout 2s) e fetchCep quando a origem é CEP. Carga sobre o MapaSUS: ~83 requests/dia do GitHub Action, ~51 MB/mês de egress (1% da cota free do Supabase).

🔍 Como Testar

  1. npm run dev
  2. Busca que atravessa as verticais: curl "localhost:3000/api/hospitais/v1?atendimento=cascavel&uf=SP&limit=3" (termo leigo → soro crotálico)
  3. Proximidade: curl "localhost:3000/api/hospitais/v1/proximos?municipio=Campinas&uf=SP&atendimento=escorpiao&raio_km=30" — conferir distancia_km crescente
  4. Vocabulário: curl "localhost:3000/api/hospitais/v1/opcoes" — todo valor listado é aceito pela busca, com o mesmo total
  5. Erro explícito: curl "localhost:3000/api/hospitais/v1?atendimento=cascavell" → 400 com orientação
  6. npm test -- hospitais haversine → 60 testes
  7. Redoc em localhost:3000/docs, grupo Saúde

📸 Screenshots (se aplicável)

N/A — sem mudanças de UI além do novo grupo no Redoc.

📎 Issues Relacionadas

Nenhuma issue aberta previamente. Se os mantenedores preferirem discutir a arquitetura em issue antes do review, abro uma com prazer.

📝 Notas Adicionais

Divulgação de conflito de interesse: o MapaSUS foi desenvolvido por mim (Codar Sistemas). Peço review e merge de outro mantenedor.

felipeflfranca and others added 9 commits August 11, 2026 02:39
Expõe os hospitais de referência habilitados pelo SUS em três verticais —
animais peçonhentos, oncologia e doenças raras — mais os centros CIATOX.
São 2.342 estabelecimentos, 99,7% geocodificados.

Os dados são extraídos de documentos públicos do Ministério da Saúde (PDFs
estaduais e planilhas de habilitação) pelo MapaSUS, que os normaliza e
geocodifica. Nem o MapaSUS nem a BrasilAPI são serviços oficiais do órgão —
toda resposta traz `fonte.oficial: false` e o aviso de procedência.

Rotas:
- GET /hospitais/v1           filtros + paginação
- GET /hospitais/v1/proximos  busca por raio, ordenada por distância
- GET /hospitais/v1/opcoes    vocabulário aceito, com contagem
- GET /hospitais/v1/ciatox    centros de assistência toxicológica

Snapshot em vez de proxy: o MapaSUS roda 100% em free tier e limita a
15 req/min. A BrasilAPI serve de lambdas efêmeras e não tem estado
compartilhado para coordenar concorrência, então um pico de tráfego viraria
uma rajada no Supabase deles. Nenhuma rota toca a rede — o dataset vem de
services/hospitais/snapshots/latest.json, atualizado por GitHub Action diário
que faz ~83 requests serializadas de um único IP. Mesmo padrão do snapshot de
sedes de bancos, com guardrail de 80% para um sync quebrado não zerar o
dataset.

Busca unificada: `?atendimento=` atravessa as três verticais e aceita nome
popular (cascavel, jararaca, armadeira), soro em PT ou EN, habilitação
(radioterapia, cacon) e código de portaria (17.07, 35.16). Sem isso a API só
responderia a quem já conhece o vocabulário em inglês. Termo desconhecido
retorna 400 em vez de esconder um typo atrás de lista vazia.

Os qualification_codes de doenças raras vêm como texto livre com ruído de
OCR, então o casamento é por extração de código via regex, nunca por texto.

Aviso de emergência em toda resposta: alguém pode consultar esta API no meio
de um acidente. A orientação é ligar 192 (SAMU) e, em caso de acidente com
peçonhento, ligar para o CIATOX antes de se deslocar — a unidade listada pode
estar sem o soro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tratamento` e `habilitacao` resolviam contra o mesmo vocabulário que
`atendimento` e devolviam resultados idênticos em todos os casos testados —
só triplicavam o que o usuário precisa decidir antes da primeira chamada.
Para restringir a uma vertical, `vertical=` já existe.

Busca principal: 9 -> 7 parâmetros. /proximos: 11 -> 10.

Também torna o merge do snapshot aditivo nos campos de lista (`verticals`,
`treatments`, `specialties`). O espalhamento anterior deixava a última coleta
sobrescrever o que as anteriores trouxeram. Não muda o dado hoje — os 5
hospitais com soro fora da vertical de peçonhentos vêm assim do MapaSUS, que
não os retorna em /v1/venomous-animals/hospitals — mas evita perda silenciosa
se o upstream passar a devolver `verticals` parcial por endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ixa de ser 500

A documentação descrevia o que cada rota retorna, não que pergunta ela
responde. Agora abre com uma tabela de perguntas reais mapeadas para a
requisição correspondente, explica para que serve cada uma das quatro rotas e
o que distingue as três verticais. Todos os exemplos foram executados contra o
servidor antes de entrar na doc.

O parâmetro `atendimento` ganhou a tabela das quatro famílias de valor que
aceita — nome popular do animal, nome do soro em PT ou EN, habilitação e
código de portaria — e `vertical` passou a dizer que é opcional e raramente
necessário.

fix: /proximos devolvia 500 para qualquer falha de CEP, vazando o
CepPromiseError cru. CEP malformado agora é 400 e CEP inexistente é 404,
usando a mesma tradução que pages/api/cep/v2/[cep].js já fazia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A revisão anterior tinha seções próprias de explicação — tabelas markdown,
títulos e blocos de código na descrição da tag —, coisa que nenhum outro
domínio do projeto faz. A tag ficou com 2.915 caracteres contra 316 da maior
das outras, quase 10x fora do padrão.

Reescrito na convenção da casa: tag em prosa curta com HTML (<b>, <code>,
<abbr>), operações em prosa de uma a três frases, e o detalhe do vocabulário
na descrição dos parâmetros — que é onde os outros domínios o colocam.

Medido contra o teto real do projeto, em texto visível sem tags:

  tag            348  (maior das outras: 316, em ISBN)
  atendimento    323  (maior parâmetro das outras: 321, em cep)
  operações  244-344  (maior das outras: 423, em cptec)

Também troca Itatiba por Campinas nos exemplos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a campo traz

Faltavam duas coisas depois do ajuste ao padrão da casa.

A tag dizia "hospitais de referência do SUS" sem nunca explicar o que isso
significa. Agora abre pela definição, como a tag de ISBN faz com ISBN: são
estabelecimentos que o Ministério da Saúde habilitou por portaria para um
atendimento específico — a habilitação é oficial, não uma classificação nossa.

E 8 dos 14 campos de resposta não tinham descrição, que é justamente onde o
Redoc responde "o que dá para obter". Todos os campos de Hospital,
CiatoxCentro e HospitalProximo agora descrevem o que trazem, incluindo duas
ressalvas que faltavam: `address` pode vir incompleto, e `lat`/`lng` são
geocodificados a partir do endereço — não vêm da fonte oficial e estão
sujeitos a erro.

A tag ficou em 390 caracteres visíveis contra 316 da maior das outras. O
excedente é a definição do domínio e o aviso de emergência.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Encontra o hospital mais próximo que oferece um atendimento" dava a entender
que a rota alcança qualquer hospital do país. Não alcança: a base cobre
exclusivamente três habilitações do Ministério da Saúde.

Os quatro summaries agora nomeiam o escopo, e as duas rotas de busca dizem no
primeiro período que a base não é um cadastro geral de hospitais.

Cabe no padrão: os summaries ficaram entre 64 e 88 caracteres, contra 108 do
maior do projeto (cep v3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… pública

Revisão de qualidade sobre a implementação. Quatro problemas reais.

Camadas invertidas: `query.js` — validação de entrada — importava de
`index.js`, o módulo que carrega 1,5 MB de snapshot, só para usar duas funções
puras. `VERTICAIS` e `resolverAtendimento` não tocam o dataset; são vocabulário
e foram para `vocabulario.js`. Agora `vocabulario.js` não tem nenhuma
dependência, `query.js` depende só dele, e `index.js` é o único que conhece o
snapshot.

Superfície pública inflada: dez exports em `vocabulario.js` e quatro constantes
exportadas em `index.js` e `query.js` que ninguém consumia de fora. Fechados —
`vocabulario.js` foi de dez exports para quatro.

Duplicação: `throw new BadRequestError({ message, type: 'validation_error' })`
aparecia seis vezes. Extraído para `invalido()`.

Coesão: `parseRaioEmMetros` era o único parser fora de `query.js`, definido
solto dentro do handler. Movido, junto com o `1000` mágico da conversão de
metros, agora `METROS_POR_KM`. `parseCoordenada(x, 'latitude', 90)` virou
`parseLatitude(x)`, tirando do handler o conhecimento dos limites.

`extrairCodigos` compilava uma RegExp nova a cada chamada, dentro do laço que
roda por hospital, e dependia de zerar `lastIndex` à mão entre chamadas de uma
regex global. As duas regex agora são compiladas uma vez e o casamento usa
`matchAll`, que não depende de `lastIndex`.

Comportamento inalterado: mesmas contagens em todas as buscas, 46 testes E2E
passando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…idade

Segunda passada de qualidade, aplicando o que a primeira tinha deixado na mesa.

Origem sai do handler: buscarCep e resolverOrigem fazem chamadas externas
(cep-promise, geocoding) e viviam em pages/api — o CLAUDE.md reserva handlers
para roteamento e resposta, e chamadas externas para services. Foram para
services/hospitais/origem.js, e o handler de /proximos caiu de 146 para 43
linhas: resolve origem, valida raio, consulta, pagina, responde.

Paginação num só lugar: o fatiamento slice(offset, offset + limit) e o
envelope {total, limit, offset, items} estavam duplicados nas duas rotas de
busca. paginar() em query.js encapsula os dois; parsePaginacao virou interno.

Contrato explícito: getHospitaisProximos repassava ...filtros por spread, o
que aceitaria silenciosamente qualquer filtro futuro sem registro. Agora nomeia
vertical e atendimento.

Retry deduplicado no script de snapshot: a chamada axios idêntica aparecia
duas vezes no caminho do 429.

Testes de unidade: vocabulario.js ficou puro e sem dependências na passada
anterior, mas a extração de códigos sobre texto sujo de OCR — a lógica mais
frágil do domínio — só era coberta de longe pelos E2E. 11 testes cobrem
aliases, equivalência de grafias, precedência, extração multi-código com
quebra de linha e ponto ausente, e a autoconsistência de /opcoes. Mais 3 para
o haversine (distância conhecida SP–Rio, zero e simetria).

Camadas agora numa direção só: vocabulario (zero deps) <- query <- origem;
index.js é o único módulo que conhece o snapshot.

Comportamento inalterado: mesmas contagens e mesmos códigos de erro em todas
as rotas. 60 testes passando (46 E2E + 14 de unidade).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…napshot

O script já chamava /v1/states e descartava tudo menos as siglas. Agora guarda
o que o MapaSUS reporta por UF no momento da coleta — status, synced_at
(última checagem da fonte) e updated_at (data publicada pelo gov.br) — em
`sincronia_da_fonte` no metrics-latest.json. Permite auditar de quando são os
dados de cada UF sem mudar o contrato da API.

Também documenta explicitamente que a coleta é completa, não incremental:
qualquer mudança de conteúdo na fonte entra no snapshot seguinte mesmo com o
número de registros inalterado — o guardrail de 80% só bloqueia encolhimento
abrupto, verificado por experimento com mock (mesmo tamanho, um nome
corrigido: a correção foi promovida).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

@felipeflfranca is attempting to deploy a commit to the BrasilAPI Team on Vercel.

A member of the Team first needs to authorize it.

@felipeflfranca felipeflfranca changed the title Feat/hospitais mapasus feat: hospitais do SUS — peçonhentos, oncologia e doenças raras Aug 11, 2026
@felipeflfranca felipeflfranca changed the title feat: hospitais do SUS — peçonhentos, oncologia e doenças raras feat: hospitais do SUS - peçonhentos, oncologia e doenças raras Aug 11, 2026
felipeflfranca and others added 3 commits August 12, 2026 11:18
- npm ci --ignore-scripts no workflow de snapshot
- node:fs / node:path nos requires
- valida MAPASUS_BASE_URL do ambiente (parse http(s) + usa só a origem
  normalizada) e monta URLs de requisição com new URL
- catches de readJsonFile e fetchStates registram o motivo do fallback
- optional chaining no tratamento de 429
- for-of nos loops sequenciais (com disable justificado do
  no-restricted-syntax do Airbnb)
- contagens derivadas da resposta HTTP passam por Number.parseInt antes
  de irem para logs e métricas; by_vertical só aceita slugs conhecidos
- vocabulario: String.raw na regex de portarias e extrairCodigos sem
  retorno invariante (Blocker do Sonar)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fecha as duas lacunas apontadas na comparação com a API do MapaSUS:

- GET /hospitais/v1/cnes/{cnes}: localiza estabelecimento pelo
  identificador oficial nacional (mais estável que o id interno da
  fonte). Normaliza zeros à esquerda e separadores perdidos na extração
  dos PDFs e devolve lista — o mesmo CNES pode aparecer em mais de um
  registro quando a fonte não consolidou as verticais. 404 explica que
  a cobertura é parcial.
- GET /hospitais/v1/estados: totais por UF + frescor reportado pela
  fonte na coleta (status, synced_at, updated_at, do sincronia_da_fonte
  do metrics) — em dado de saúde, 'de quando é isso?' importa tanto
  quanto o dado.

Ambas servidas do snapshot em memória, com OpenAPI e E2E (10 testes
novos).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…snapshot

A allowlist introduzida na rodada do Sonar usava os slugs da API em
português (peconhentos, oncologia, raras), mas hospital.verticals traz
as chaves de banco do MapaSUS (venomous_animals, oncology,
rare_diseases) — o by_vertical das métricas vinha vazio. A allowlist
agora deriva das chaves reais.

Snapshot regenerado com a coleta de 12/08: 2342 hospitais (2336
geocodificados), 32 ciatox e sincronia_da_fonte preenchido para as 27
UFs — é o que alimenta o /hospitais/v1/estados.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant