Iniciativa Cidadã Independente · Transparência Parlamentar
TS296 linhas8.871 bytes

apps/worker/src/services/adm.ts

SHA-256

aa685211a417703dc2c0f9ed27476884e9a9a3fcd520b54af4bd1c3fe64ef74c

Somente leiturafonte-acf635076da2
import type { Env, CeapMes } from '../types'

const CACHE_TTL_SECONDS = 6 * 60 * 60
const LEG57_ANOS = [2023, 2024, 2025, 2026]
const TENTATIVAS = 3

/**
 * Upstream do ADM fora do ar ou respondendo em formato inesperado.
 *
 * Existe para que uma indisponibilidade NUNCA seja confundida com "senador
 * não teve despesas": em 2026-08-02 uma falha silenciosa zerou o CEAP dos 81
 * senadores e o ranking persistiu esses zeros por uma semana inteira.
 */
export class AdmIndisponivelError extends Error {
  constructor(url: string, motivo: string) {
    super(`ADM indisponível (${url}): ${motivo}`)
    this.name = 'AdmIndisponivelError'
  }
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

/**
 * Extrai a lista de registros. O ADM serve tanto array puro (despesas_ceaps)
 * quanto o envelope `{ statusCode, msg, data: [...] }` (endpoints migrados em
 * 2026). Qualquer outra forma devolve null e é tratada como falha.
 */
function extrairRegistros(payload: unknown): Record<string, unknown>[] | null {
  if (Array.isArray(payload)) return payload as Record<string, unknown>[]
  if (payload && typeof payload === 'object') {
    const interno = (payload as { data?: unknown }).data
    if (Array.isArray(interno)) return interno as Record<string, unknown>[]
  }
  return null
}

async function fetchAdm(url: string): Promise<Record<string, unknown>[]> {
  let motivo = 'desconhecido'
  for (let tentativa = 0; tentativa < TENTATIVAS; tentativa++) {
    try {
      const res = await fetch(url, {
        headers: { Accept: 'application/json' },
        signal: AbortSignal.timeout(20_000),
      })
      if (!res.ok) {
        motivo = `HTTP ${res.status}`
      } else {
        const registros = extrairRegistros(await res.json())
        if (registros) return registros
        motivo = 'resposta sem lista de registros'
      }
    } catch (err) {
      motivo = (err as Error).message || String(err)
    }
    // adm.senado.gov.br dá ConnectTimeout esporádico; backoff 0.5s / 1s
    if (tentativa < TENTATIVAS - 1) await sleep(500 * 2 ** tentativa)
  }
  throw new AdmIndisponivelError(url, `${motivo} (${TENTATIVAS} tentativas)`)
}

/**
 * Sempre devolve uma lista de registros ou lança. Só grava no cache payload já
 * validado, para não envenenar o cache com resposta degradada.
 */
async function cachedFetch(
  env: Env,
  cacheKey: string,
  url: string,
): Promise<Record<string, unknown>[]> {
  const cached = await env.SENADO_CACHE.get(cacheKey, 'json')
  if (cached !== null) {
    const registros = extrairRegistros(cached)
    if (registros) return registros
  }

  const dados = await fetchAdm(url)
  await env.SENADO_CACHE.put(cacheKey, JSON.stringify(dados), {
    expirationTtl: CACHE_TTL_SECONDS,
  })
  return dados
}

async function getCeapByYear(
  env: Env,
  ano: number,
): Promise<Record<string, number>> {
  const cacheKey = `adm:ceap:${ano}`
  const data = await cachedFetch(
    env,
    cacheKey,
    `${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
  )

  const totais: Record<string, number> = {}
  for (const reg of data) {
    const cod = String(reg?.codSenador ?? '')
    const valor = Number(reg?.valorReembolsado ?? 0)
    if (cod && cod !== 'undefined') {
      totais[cod] = (totais[cod] ?? 0) + valor
    }
  }
  return totais
}

/**
 * Retorna CEAP total acumulado de cada senador na legislatura 57 inteira.
 *
 * Lança se o resultado vier vazio: uma legislatura inteira sem nenhuma despesa
 * é sempre falha de upstream, nunca dado real. Sem essa guarda o ranking
 * persiste zeros e o site mostra "sem cota" para todos os senadores.
 */
export async function getCeapLeg57(
  env: Env,
): Promise<Record<string, number>> {
  const totais: Record<string, number> = {}
  for (const ano of LEG57_ANOS) {
    const ceapAno = await getCeapByYear(env, ano)
    for (const [cod, valor] of Object.entries(ceapAno)) {
      totais[cod] = (totais[cod] ?? 0) + valor
    }
  }

  if (Object.keys(totais).length === 0) {
    throw new AdmIndisponivelError(
      `${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps`,
      `nenhuma despesa em ${LEG57_ANOS.join('/')} — upstream degradado`,
    )
  }
  return totais
}

export async function getCeapMensal(
  env: Env,
  codigoParlamentar: string,
  ano: number,
): Promise<CeapMes[]> {
  const cacheKey = `adm:ceap:ano:${ano}`
  const data = await cachedFetch(
    env,
    cacheKey,
    `${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
  )

  const meses: Record<number, number> = {}
  for (const reg of data) {
    const cod = String(reg?.codSenador ?? '')
    if (cod !== codigoParlamentar) continue
    const mes = Number(reg?.mes ?? 0)
    const valor = Number(reg?.valorReembolsado ?? 0)
    if (mes >= 1 && mes <= 12) {
      meses[mes] = (meses[mes] ?? 0) + valor
    }
  }

  return Object.entries(meses)
    .map(([mes, valorTotal]) => ({ mes: parseInt(mes, 10), ano, valorTotal }))
    .sort((a, b) => a.mes - b.mes)
}

/** ADM devolve flags como "S"/"N" — Boolean("N") seria `true`. */
function flagSN(valor: unknown): boolean {
  if (typeof valor === 'boolean') return valor
  return String(valor ?? '').trim().toUpperCase() === 'S'
}

/**
 * Retorna mapa nomeNormalizado → { auxilioMoradia, imovelFuncional }
 * (API não tem código do senador, usa nome — fazemos match por nome normalizado).
 */
export async function getAuxiliosMoradia(
  env: Env,
): Promise<Record<string, { auxilioMoradia: boolean; imovelFuncional: boolean }>> {
  const data = await cachedFetch(
    env,
    'adm:auxilios:moradia',
    `${env.ADM_BASE_URL}/api/v1/senadores/auxilio-moradia`,
  )

  const result: Record<string, { auxilioMoradia: boolean; imovelFuncional: boolean }> = {}
  for (const reg of data) {
    const nome = normalizeNome(String(reg?.nomeParlamentar ?? ''))
    if (!nome) continue
    result[nome] = {
      auxilioMoradia: flagSN(reg?.auxilioMoradia),
      imovelFuncional: flagSN(reg?.imovelFuncional),
    }
  }
  return result
}

/**
 * Retorna mapa codSenador → { total, divulgacao, escritorio, locomocao, consultoria, outros }
 * para a legislatura inteira (somando todos os anos).
 */
export async function getCeapBreakdownLeg57(
  env: Env,
): Promise<
  Record<
    string,
    {
      total: number
      divulgacao: number
      escritorio: number
      locomocao: number
      consultoria: number
      outros: number
    }
  >
> {
  const out: Record<
    string,
    {
      total: number
      divulgacao: number
      escritorio: number
      locomocao: number
      consultoria: number
      outros: number
    }
  > = {}

  for (const ano of LEG57_ANOS) {
    const data = await cachedFetch(
      env,
      `adm:ceap:ano:${ano}`,
      `${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
    )
    for (const reg of data) {
      const cod = String(reg?.codSenador ?? '')
      if (!cod || cod === 'undefined') continue
      const valor = Number(reg?.valorReembolsado ?? 0)
      const tipo = String(reg?.tipoDespesa ?? '').toLowerCase()

      if (!out[cod]) {
        out[cod] = {
          total: 0,
          divulgacao: 0,
          escritorio: 0,
          locomocao: 0,
          consultoria: 0,
          outros: 0,
        }
      }
      const b = out[cod]
      b.total += valor

      if (tipo.includes('divulga')) {
        b.divulgacao += valor
      } else if (tipo.includes('escritório') || tipo.includes('aluguel') || tipo.includes('material de consumo')) {
        b.escritorio += valor
      } else if (tipo.includes('passag') || tipo.includes('locomo') || tipo.includes('combust')) {
        b.locomocao += valor
      } else if (tipo.includes('consultor') || tipo.includes('assessoria') || tipo.includes('pesquisa')) {
        b.consultoria += valor
      } else {
        b.outros += valor
      }
    }
  }
  return out
}

/**
 * Retorna mapa nomeNormalizado → numero de escritórios de apoio.
 * (API não expoe código do senador, usamos nome.)
 */
export async function getEscritoriosCount(
  env: Env,
): Promise<Record<string, number>> {
  // /escritorios responde 302 → /escritorios-apoio desde 2026-06; o nome do
  // parlamentar passou a vir aninhado em `parlamentar.nome`.
  const data = await cachedFetch(
    env,
    'adm:escritorios:lista',
    `${env.ADM_BASE_URL}/api/v1/senadores/escritorios-apoio`,
  )
  const out: Record<string, number> = {}
  for (const reg of data) {
    const parlamentar = reg?.parlamentar as { nome?: unknown } | undefined
    const nome = normalizeNome(String(parlamentar?.nome ?? reg?.nome ?? ''))
    if (!nome) continue
    out[nome] = (out[nome] ?? 0) + 1
  }
  return out
}

export function normalizeNome(nome: string): string {
  return nome
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9 ]/g, '')
    .replace(/\s+/g, ' ')
    .trim()
}