Iniciativa Cidadã Independente · Transparência Parlamentar
TS366 linhas12.639 bytes

apps/worker/src/index.ts

SHA-256

368897ba54a19bb2b2a00c9a7acb511d71dece35037bb7f6d8a2b83474e9e72b

Somente leiturafonte-acf635076da2
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { ranking } from './routes/ranking'
import { senador } from './routes/senador'
import { ceap } from './routes/ceap'
import { newsletter } from './routes/newsletter'
import { computeRanking, persistRanking, getLatestRanking } from './services/ranking'
import { getSenadorList, getRawDimensions, mesesAtivos } from './services/legis'
import { getCeapLeg57, getAuxiliosMoradia } from './services/adm'
import { gerarPreview, enviarEdicao } from './services/newsletter'
import type { Env } from './types'

export const app = new Hono<{ Bindings: Env }>()

app.use('*', logger())
app.use(
  '*',
  cors({
    origin: [
      'https://observasenado.pages.dev',
      'https://observasenado.org',
      'https://www.observasenado.org',
      'http://localhost:3000',
      'http://localhost:3001',
    ],
    allowHeaders: ['Content-Type', 'Authorization', 'X-Admin-Secret'],
    allowMethods: ['GET', 'POST', 'OPTIONS'],
    maxAge: 86400,
  }),
)

app.get('/health', async (c) => {
  const scores = await getLatestRanking(c.env)
  const dims = [
    'dimProdutividade',
    'dimEfetividade',
    'dimParticipacao',
    'dimFiscalizacao',
    'dimCeap',
    'dimTransparencia',
  ] as const
  const modePct: Record<string, number> = {}
  let dimensionsHealthy = true
  if (scores.length > 0) {
    for (const dim of dims) {
      const counts = new Map<number, number>()
      for (const s of scores) {
        const v = (s as unknown as Record<string, number>)[dim]
        counts.set(v, (counts.get(v) ?? 0) + 1)
      }
      const top = Math.max(...counts.values())
      const pct = top / scores.length
      modePct[dim] = Math.round(pct * 1000) / 10
      if (pct > 0.5 || counts.size <= 5) dimensionsHealthy = false
    }
  } else {
    dimensionsHealthy = false
  }
  return c.json({
    status: 'ok',
    ts: new Date().toISOString(),
    ranking_count: scores.length,
    ranking_ready: scores.length > 0,
    dimensions_healthy: dimensionsHealthy,
    mode_pct: modePct,
  })
})

app.route('/api/ranking', ranking)
app.route('/api/senador', senador)
app.route('/api/ceap', ceap)
app.route('/api/newsletter', newsletter)

// Endpoint admin: dispara recálculo manual (autenticado via X-Admin-Secret)
app.post('/admin/recalculate', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  try {
    const scores = await computeRanking(c.env)
    await persistRanking(c.env, scores)
    return c.json({
      status: 'ok',
      ranking_count: scores.length,
      computed_at: new Date().toISOString(),
    })
  } catch (err) {
    console.error('[admin/recalculate] erro:', err)
    return c.json(
      { error: 'recalc failed', detail: String(err) },
      500,
    )
  }
})

// Warmup: popula KV cache em chunks (workaround p/ limite de 1000 subrequests)
// Uso: POST /admin/warmup?offset=0&limit=20
app.post('/admin/warmup', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  const offset = Number(c.req.query('offset') ?? '0')
  const limit = Number(c.req.query('limit') ?? '5')

  try {
    // Pré-aquece auxiliares globais 1 vez (CEAP + auxilio moradia)
    if (offset === 0) {
      await Promise.allSettled([
        getCeapLeg57(c.env),
        getAuxiliosMoradia(c.env),
      ])
    }

    const senadores = await getSenadorList(c.env)
    const slice = senadores.slice(offset, offset + limit)

    const results = await Promise.allSettled(
      slice.map((s) => getRawDimensions(c.env, s.codigo)),
    )

    // Persiste rawDimensions consolidado por senador (1 chave KV por sen)
    // Permite que /admin/recalculate funcione com poucos subrequests
    let ok = 0
    let fail = 0
    let suspeitos = 0
    await Promise.all(
      results.map(async (r, idx) => {
        if (r.status !== 'fulfilled') {
          fail += 1
          return
        }
        const sen = slice[idx]
        const v = r.value
        // Guarda anti cache-poisoning: senador veterano (>3 meses) sem
        // NENHUM sinal em 4 dimensões independentes é quase certamente
        // cascata de fetches falhos, não realidade.
        //
        // Usa mesesAtivos da coorte (exercício real na Leg 57), não a distância
        // até hoje: para quem encerrou o mandato a data de início ficaria a
        // anos de distância e transformaria 1 mês de exercício em "veterano".
        const meses =
          sen.mesesAtivos ??
          mesesAtivos(sen.dataInicioExercicio ?? '2023-02-01')
        const semSinal =
          v.efetividadeBase === 0 &&
          v.relatoriasTotal === 0 &&
          v.discursosTotal === 0 &&
          v.votacoesTotal <= 1
        if (meses > 3 && semSinal) {
          suspeitos += 1
          fail += 1
          return
        }
        await c.env.SENADO_CACHE.put(
          `raw:v2:${sen.codigo}`,
          JSON.stringify(v),
          { expirationTtl: 8 * 24 * 60 * 60 }, // 8 dias > 1 semana
        )
        ok += 1
      }),
    )

    return c.json({
      status: 'ok',
      offset,
      processed: slice.length,
      ok,
      fail,
      suspeitos,
      next_offset: offset + slice.length,
      total: senadores.length,
      done: offset + slice.length >= senadores.length,
    })
  } catch (err) {
    console.error('[admin/warmup] erro:', err)
    return c.json({ error: 'warmup failed', detail: String(err) }, 500)
  }
})

// Purge de caches específicos (KV) — útil após mudanças de classificação
// Uso: POST /admin/purge?keys=raw,processos,cargos&offset=0&limit=15
app.post('/admin/purge', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  const keysParam = String(
    c.req.query('keys') ?? 'raw,processos,cargos,liderancas',
  )
  const targets = keysParam.split(',').map((k) => k.trim())
  const offset = Number(c.req.query('offset') ?? '0')
  const limit = Number(c.req.query('limit') ?? '15')

  const senadores = await getSenadorList(c.env)
  const slice = senadores.slice(offset, offset + limit)
  let deleted = 0
  await Promise.all(
    slice.map(async (s) => {
      const ks: string[] = []
      if (targets.includes('raw')) ks.push(`raw:v2:${s.codigo}`)
      if (targets.includes('processos'))
        ks.push(`legis:${s.codigo}:processos:leg57`)
      if (targets.includes('cargos')) ks.push(`legis:${s.codigo}:cargos`)
      if (targets.includes('liderancas'))
        ks.push(`legis:${s.codigo}:liderancas`)
      await Promise.all(ks.map((k) => c.env.SENADO_CACHE.delete(k)))
      deleted += ks.length
    }),
  )
  return c.json({
    status: 'ok',
    deleted,
    targets,
    offset,
    processed: slice.length,
    next_offset: offset + slice.length,
    total: senadores.length,
    done: offset + slice.length >= senadores.length,
  })
})

// Migrações manuais via admin (D1) — idempotentes
app.post('/admin/migrate', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  const migrations = [
    `ALTER TABLE ranking_snapshots ADD COLUMN votacoes_comissao_presentes INTEGER DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN votacoes_comissao_total INTEGER DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ceap_divulgacao REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ceap_escritorio REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ceap_locomocao REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ceap_consultoria REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ceap_outros REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN pct_divulgacao REAL DEFAULT 0`,
    `ALTER TABLE ranking_snapshots ADD COLUMN escritorios_count INTEGER DEFAULT 0`,
    `CREATE TABLE IF NOT EXISTS newsletter_subscribers (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT NOT NULL UNIQUE,
      status TEXT NOT NULL DEFAULT 'pending',
      token TEXT NOT NULL,
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      confirmed_at TEXT,
      unsubscribed_at TEXT,
      ip TEXT,
      user_agent TEXT
    )`,
    `CREATE INDEX IF NOT EXISTS idx_newsletter_status ON newsletter_subscribers(status)`,
    `CREATE INDEX IF NOT EXISTS idx_newsletter_token  ON newsletter_subscribers(token)`,
    // IDS v3
    `ALTER TABLE ranking_snapshots ADD COLUMN status TEXT`,
    `ALTER TABLE ranking_snapshots ADD COLUMN confianca TEXT`,
    `ALTER TABLE ranking_snapshots ADD COLUMN ids_total_bruto REAL`,
    `CREATE INDEX IF NOT EXISTS idx_ranking_status ON ranking_snapshots(status)`,
    // Newsletter runs (auditoria + idempotência semanal)
    `CREATE TABLE IF NOT EXISTS newsletter_runs (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      computed_at TEXT NOT NULL DEFAULT (datetime('now')),
      edicao TEXT NOT NULL UNIQUE,
      subject TEXT NOT NULL,
      preheader TEXT,
      html TEXT NOT NULL,
      markdown TEXT NOT NULL,
      prompt_tokens INTEGER,
      output_tokens INTEGER,
      modelo TEXT,
      status TEXT NOT NULL DEFAULT 'preview',
      enviados INTEGER DEFAULT 0,
      falhas INTEGER DEFAULT 0,
      enviado_em TEXT,
      aprovado_em TEXT
    )`,
    `CREATE INDEX IF NOT EXISTS idx_newsletter_runs_status ON newsletter_runs(status)`,
  ]
  const results: { sql: string; ok: boolean; err?: string }[] = []
  for (const sql of migrations) {
    try {
      await c.env.SENADO_DB.prepare(sql).run()
      results.push({ sql, ok: true })
    } catch (err) {
      const msg = String(err)
      // duplicate column name = já aplicado
      const dup = msg.includes('duplicate column name') || msg.includes('already exists')
      results.push({ sql, ok: dup, err: dup ? 'already-applied' : msg })
    }
  }
  return c.json({ status: 'ok', results })
})

// Newsletter — admin only
app.post('/admin/newsletter/preview', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) return c.json({ error: 'Unauthorized' }, 401)
  const forcar = c.req.query('forcar') === '1'
  try {
    const p = await gerarPreview(c.env, forcar)
    const accept = c.req.header('Accept') ?? ''
    if (accept.includes('text/html')) {
      return c.html(p.html)
    }
    return c.json({
      edicao: p.edicao,
      subject: p.subject,
      preheader: p.preheader,
      modelo: p.modelo,
      promptTokens: p.promptTokens,
      outputTokens: p.outputTokens,
      status: p.status,
      reused: p.reused,
      markdown: p.markdown,
      // html omitido do JSON por tamanho — pegar via Accept: text/html
    })
  } catch (err) {
    console.error('[admin/newsletter/preview]', err)
    return c.json({ error: 'preview failed', detail: String(err) }, 500)
  }
})

app.post('/admin/newsletter/enviar', async (c) => {
  const secret = c.req.header('X-Admin-Secret')
  if (!secret || secret !== c.env.ADMIN_SECRET) return c.json({ error: 'Unauthorized' }, 401)
  const edicao = c.req.query('edicao')
  if (!edicao) return c.json({ error: 'edicao obrigatória' }, 400)
  const adminOnly = c.req.query('adminOnly') === '1'
  const adminEmail = c.req.query('email') ?? undefined
  try {
    const r = await enviarEdicao(c.env, edicao, { adminOnly, adminEmail })
    return c.json(r)
  } catch (err) {
    console.error('[admin/newsletter/enviar]', err)
    return c.json({ error: 'envio falhou', detail: String(err) }, 500)
  }
})

app.notFound((c) => c.json({ error: 'Not found' }, 404))
app.onError((err, c) => {
  console.error('[worker error]', err)
  return c.json({ error: 'Internal server error' }, 500)
})

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return app.fetch(request, env, ctx)
  },

  // Cron: roda 1x por semana, domingos às 03:00 UTC
  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
    ctx.waitUntil(
      (async () => {
        console.log('[cron] Iniciando recálculo semanal do ranking IDS...')
        try {
          const scores = await computeRanking(env)
          await persistRanking(env, scores)
          console.log(`[cron] OK — ${scores.length} senadores calculados`)
        } catch (err) {
          console.error('[cron] Erro:', err)
        }
      })(),
    )
  },
}