feat(B189): Sollinha — municípios sem atualização recente (tool getMunicipalitiesWithoutUpdate)
Tool nova da família B185/B186: cobertura do acompanhamento por recência de municipality.lastUpdateAt (derivado dos hooks de municipalityUpdate), limiar default 30 ajustável, lista exaustiva com total, nunca atualizados no topo, assessores por item. Resolver de escopo extraído para aiToolScope.ts (2o call site; B186 sera o 3o). Gate staff fail-closed; unit tests 14 novos + regressao B185 verde.
This commit is contained in:
parent
32b6929ddb
commit
6027aac941
8 changed files with 694 additions and 84 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-10):** **B189** (Issue #526) — o Sollinha ganhou a tool **`getMunicipalitiesWithoutUpdate`** (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`): "quais municípios estão sem atualização há mais de X dias" (cobertura do acompanhamento) no escopo do usuário, com **contagem total exaustiva** (sem top N — cobertura é "tudo além do limiar"), **limiar declarado na resposta** (`limiarDias` + `criterio` interpolado; default 30, ajustável por pergunta via `days` 1–365, estritamente maior), lista ordenada do mais velho ao mais recente com "nunca atualizado" no topo (estagnação máxima, sempre incluídos), itens com `diasSemAtualizacao`/`cidade`/`regiao`/`tipo` (ZE vs município inteiro)/**assessores** (nomes em query própria `campaignUser` — `name` não é identity-gated; nunca `depth`). **Fonte de recência = `municipality.lastUpdateAt`** (campo derivado pelos hooks de `municipalityUpdate`, admin read-only — a semântica compartilhada da família B185 "última atualização de campo", nunca `updatedAt` do município; sanity local provou create→coluna→delete→null via hooks); filtro de recência em JS com `municipalitySignalAgeInDays` (helper E9 reusado — coerência rótulo↔limiar sem boundary de instante no `where`). **Gate staff inline** (leader → `{ error: 'Leitura de municípios negada.' }` antes de qualquer query, shape da B180); leituras `overrideAccess: false, user: ctx.user` (assessor = portfólio automático); **guard fail-closed de escopo vazio** (assessor fora do portfólio → shape vazio escopado sem leitura). **Resolver de escopo extraído** da B185 para `src/utilities/ai/tools/aiToolScope.ts` (`resolveAIToolScope` — Salvador primeiro, município por alias, território acento-tolerante; 2º call site da família; nomes de tipos alinhados à extração em voo da B186 para o merge dela vir trivial; contrato da B185 inalterado, spec existente cobre a regressão) e **loader de nomes compartilhado** `aiToolQueries.ts` (fundiu `loadNamesByIds` da B185 com o lookup local — don't twin). Seção nova no system prompt: declarar sempre limiar+critério, resumir Salvador ("7 das 19 zonas sem atualização há mais de 30 dias") e agrupar por assessor quando pedido (mesmo precedente "o modelo agrupa pela lista" da B185). Unit tests novos (`municipalitiesWithoutUpdateTool.unit.spec.ts`, 15): gate fail-closed com payload intocado, critério + borda estrita (31→incluído/30→excluído/`days:15`), ordem (nunca por nome, depois mais velho→recente), escopos acento/Salvador/município/erro, ids do escopo no where, escopo vazio fail-closed, nomes de assessores, tipo ZE. Sem migration/access/Consent/UI. Intenção [`sollinha-municipios-sem-atualizacao-recente.md`](plans/sollinha-municipios-sem-atualizacao-recente.md); impl [`sollinha-municipios-sem-atualizacao-recente-impl.md`](plans/sollinha-municipios-sem-atualizacao-recente-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **OPS21** (Issue #538) — `REVALIDATE_SECRET` adicionada na **Vercel production** (projeto `solla/jorgesolla`): diagnóstico D6 confirmado (env ausente em `vercel env ls production`; `POST /api/revalidate` respondia `500` "not configured" em prod, quebrando o runbook pós-seed/mudança direta de DB). Segredo gerado com `openssl rand -hex 32` (valor via stdin, nunca argv), `vercel env add REVALIDATE_SECRET production`; **fallback necessário:** o deploy existente congelava as envs em runtime → `vercel redeploy` do último deploy de produção (2×, pois o primeiro `env add` via pipe herdou o `\n` do `openssl` e gravou valor de 66 chars — removido e re-adicionado limpo). Validação real em prod: sem secret → `401`; secret errado → `401`; secret certo → `200 { revalidated: true, tag: 'posts' }`; tag desconhecida → `400`. Preview não adicionada (projeto sem Git conectado — `vercel env add ... preview` exige branch, precedente D6). Sem código/migration/access/UI. Impl [`revalidate-secret-vercel-production-impl.md`](plans/revalidate-secret-vercel-production-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `<form>`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md).
|
||||
|
|
|
|||
100
docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md
Normal file
100
docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Impl: Sollinha — municípios sem atualização recente (cobertura do acompanhamento)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #526
|
||||
Intenção: docs/plans/sollinha-municipios-sem-atualizacao-recente.md
|
||||
Appetite restante: ~0,5–1 dia eng (herdado); sem migration / Consent / collection / UI nova
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o Sollinha responde "quais municípios estão sem atualização há mais de X dias" no **escopo do usuário** (coordenador = estado; assessor = portfólio; leader = deny fail-closed), com contagem total, um item por município com "há N dias" (ou "nunca atualizado" no topo, estagnação máxima), limiar declarado na resposta ("sem atualização há 30+ dias") e ajustável por pergunta; refinável por região/cidade; agrupável por assessor quando pedido; links de navegação para o detalhe de cada município (B162).
|
||||
- **O que NÃO negociar:** leader lockdown (fail-closed antes de qualquer query — shape do gate da B180); assessor vê só o portfólio (RBAC atual das collections, `overrideAccess: false, user: ctx.user`); "sem atualização" mede **só** `municipalityUpdate` (semântica compartilhada da família B185 — nunca `updatedAt` do município); lista exaustiva com contagem (cobertura é "tudo além do limiar", não top N); limiar sempre visível na resposta; sem estimativas de votos (assimetria intacta); sem dados eleitorais; sem write.
|
||||
- **O que reavaliar:**
|
||||
1. A hipótese "fonte: `municipalityUpdate` (createdAt) × `municipality`" — o campo derivado **`municipality.lastUpdateAt`** (admin read-only, `index: true`, mantido pelos hooks de `MunicipalityUpdate` — `recomputeMunicipalityLastUpdateAt` — em create/update/delete) **já é** exatamente essa semântica, sempre consistente. Re-derivar agregando `municipalityUpdate` por município na tool seria gemiar lógica que o banco já mantém e divergir da coluna que a UI exibe. A tool lê `lastUpdateAt` direto.
|
||||
2. A recomendação da intenção de "agrupar Salvador por cidade por padrão com opção de detalhar por ZE" — tratado como decisão de forma (ver Abordagem): itens por ZE com `cidade: 'Salvador'` + instrução de prompt para o modelo resumir a capital ("Salvador: 7 das 19 zonas…"), mesmo precedente do corte "agregado na tool" da B185 (o modelo agrupa pela lista; sem modo de saída extra).
|
||||
3. Questões abertas da intenção: limiar default **30** dias (validado no gate; ajustável por `days` na pergunta); "sem atualização" = `lastUpdateAt` (derivado de `municipalityUpdate`) — opção (a) da intenção, coerente com a família.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[ai-chat route] --> B[buildAITools]
|
||||
B --> T[getMunicipalitiesWithoutUpdate ctx]
|
||||
T --> G[gate staff inline<br/>leader → { error }]
|
||||
G --> S[resolveAIToolScope<br/>município → Salvador → região]
|
||||
S --> Q1[find municipality<br/>escopo + access do usuário<br/>select lastUpdateAt/advisors/…]
|
||||
Q1 --> F[idade = ageInDays(lastUpdateAt)<br/>inclui null sempre; > days?]
|
||||
F --> Q2[find campaignUser<br/>nomes dos assessores]
|
||||
Q2 --> R[{ escopo, limiarDias, criterio,<br/>total, nuncaAtualizados, municipios }]
|
||||
R --> L[buildCampaignLinks<br/>municipality por slug]
|
||||
```
|
||||
|
||||
**Opções consideradas:**
|
||||
|
||||
- **A — Tool nova `getMunicipalitiesWithoutUpdate`** lendo `municipality.lastUpdateAt` (fonte única derivada), filtro de recência em JS sobre dias já arredondados (`municipalitySignalAgeInDays`), escopo via resolver compartilhado extraído da B185.
|
||||
- **B — Tool lendo `municipalityUpdate`** e agregando max(createdAt) por município na execução.
|
||||
- **C — Filtro de recência no `where`** (`lastUpdateAt: { less_than: cutoff }` + `exists: false`), com sort nativo.
|
||||
- **D — Reaproveitar `getPendingLeaderships`** (modo novo) em vez de tool nova.
|
||||
|
||||
**Recomendação: A** — a semântica de recência já vive em `municipality.lastUpdateAt` (o banco é o dono; a UI inteira — lista, dossier — lê esse campo; a tool leitura não re-deriva nada). O filtro em JS com `ageInDays` arredondado garante **coerência rótulo↔critério** ("há 30 dias" nunca aparece sob limiar "30+") sem depender de boundary de instante no `where`. **Rejeitadas:** B (gemia a derivação; divergiria de `lastUpdateAt` se os hooks mudassem; query mais cara), C (o `less_than` no instante cru e o rótulo em dias arredondados discordam na borda — item de 30,4 dias entraria no where mas rotularia "há 30 dias" sob um limiar declarado "mais de 30"), D (a B185 responde outro aceite — pendências de abordagem; o eixo aqui é cobertura por recência, e o modo esconderia a tool da descoberta do modelo).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`resolveAIToolScope`** (`src/utilities/ai/tools/aiToolScope.ts`, novo): extração da resolução de escopo da B185 (2º call site da família; B186 será o 3º — regra "edit the owner, don't twin"): `resolveScope`/`resolveMunicipalities`/`ScopeMunicipality`/`ResolvedScope` de `getPendingLeaderships.ts` movidos com nomes exportados (`resolveAIToolScope`, `AIToolScopeMunicipality`, `AIToolResolvedScope`), mesmo comportamento (Salvador primeiro → município via `resolveMunicipalityName` → território por `normalizeSearchPhrase` → erro "Escopo não reconhecido…"), `overrideAccess: false, user: ctx.user`.
|
||||
- **`getPendingLeaderships.ts`** (refactor): passar a importar o resolver compartilhado; remover as definições locais — comportamento idêntico, sem mudança de contrato (spec existente `pendingLeadershipsTool.unit.spec.ts` continua verde sem edição).
|
||||
- **`getMunicipalitiesWithoutUpdate`** (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`, novo): factory `tool()` com input Zod `{ scope?: string, days?: number (1–365, default 30) }`; gate inline no topo do execute (2º call site do padrão staff — regra 2=inline: `if (!isStaffCampaignRole(ctx.user.role)) return { error: 'Leitura de municípios negada.' }`, shape do chat da B180); uma `find` em `municipality` (where do escopo + access automático do assessor; `select: { id, name, slug, city, region, kind, lastUpdateAt, advisors }`, `depth: 0`, `limit: 0`, `pagination: false`); partição e ordenação em JS; uma `find` em `campaignUser` para nomes dos assessores (`select: { name: true }` — `name` não é identity-gated, ver `canReadCampaignUserIdentity` aplica-se só a contact/email/phone).
|
||||
- **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx)`.
|
||||
- **`systemPrompt.ts`** (`src/utilities/ai/`): seção "Municípios sem atualização recente" — quando usar; **declarar sempre o limiar e o critério** devolvidos pela tool; "nunca atualizado" no topo; quando o escopo incluir Salvador, resumir a capital no início (ex.: "Salvador: 7 das 19 zonas…") e detalhar por ZE sob pedido; `escopoRestrito: true` → dizer que a lista é do portfólio; links via `buildCampaignLinks` (destination `municipality` por slug) para os citados.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** nenhuma chave nova; gate fail-closed via `isStaffCampaignRole` (2 call sites — inline, sem extrair HOF); leituras delegadas ao access control (`canReadMunicipality` — assessor portfólio; leader nem chega a query).
|
||||
- **UI:** Impeccable A — N/A (resposta em texto no chat existente; sem superfície nova).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma: **lista exaustiva em texto no chat** com contagem (`total`), itens ordenados do mais velho ao mais recente (nunca atualizados no topo), cada item com `diasSemAtualizacao`/`nuncaAtualizado` e `cidade`/`regiao`/`assessores` para o modelo agrupar sob pedido ("agrupa por assessor" = o modelo agrupa pelos nomes). **Rejeitadas:** agrupamento por cidade/assessor como modo de saída da tool (o modelo agrupa pela lista — mesma leitura, precedente B185); top N com truncamento (contrato é exaustivo: "tudo além do limiar"; o modelo comprime listas longas e sugere estreitar o escopo); percentuais/estatísticas (anti-goal da intenção).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
1. **Fonte de recência = `municipality.lastUpdateAt`.** Derivado pelos hooks de `MunicipalityUpdate` (create/update/delete → recompute do max createdAt por município), admin read-only, `index: true`. A tool nunca toca `municipalityUpdate`; nunca usa `municipality.updatedAt` (edição de config não é acompanhamento de campo — decisão (a) da intenção, família B185). Verificação na implementação: conferir na DB local que `lastUpdateAt` reflete o último `municipalityUpdate.createdAt` após create/delete (spec de hooks já cobre; sanity manual).
|
||||
2. **Critério e coerência rótulo↔limiar.** `dias = municipalitySignalAgeInDays(lastUpdateAt)` (floor, `DAY_MS` — helper puro da família E9, reusado; não duplicar a conta). Inclui: `dias === null` (nunca atualizado — sempre, estagnação máxima) ou `dias > days` (estritamente maior). **Rejeitada:** filtro no `where` por instante (`less_than: now - days*DAY_MS`) — discorda do rótulo arredondado na borda e mistura dois relógios. O where do `find` carrega só escopo (região/cidade/nome); recência é partição em JS sobre ≤435 docs — custo irrelevante.
|
||||
3. **Ordenação.** Partição "nunca atualizado" primeiro (nome asc como desempate determinístico), depois por `lastUpdateAt` asc (mais velho primeiro; desempate nome asc). Determinística e testável — a resposta do modelo segue a ordem devolvida.
|
||||
4. **Gate.** Inline (2 call sites da família hoje — 1 por tool): `isStaffCampaignRole` → senão `{ error: 'Leitura de municípios negada.' }`. **Rejeitada:** extrair HOF estilo `electionDataGate` (B185 registrou a mesma escolha; 2 sites ainda não justificam — revisitar na B186, 3º da família).
|
||||
5. **Escopo compartilhado.** Extração da B185 para `aiToolScope.ts` — comportamento idêntico (ordem Salvador→município→território, acento-tolerante), tipos nomeados. B185 passa a importar; contrato da tool da B185 inalterado (spec existente cobre a regressão).
|
||||
6. **Assessores.** `municipality.advisors` (hasMany → campaignUser, ids com depth 0) + lookup de nomes em `campaignUser` com `overrideAccess: false, user: ctx.user` (`name` é legível por qualquer campaign user; `canReadCampaignUserIdentity` só protege contact/email/phone). Item com `assessores: [{ id, nome }]` (nome `null` só se o usuário sumiu — mantém id, sem vazar vínculo fora do acesso porque a leitura já é do ator).
|
||||
7. **Topologia da resposta.** `{ escopo: { tipo, nome }, escopoRestrito, limiarDias, criterio, total, nuncaAtualizados, municipios: [...] }` — `total` = `municipios.length` (exaustivo; sem `truncado`); `nuncaAtualizados` = contagem da partição "nunca" (o modelo usa para o resumo de Salvador). `criterio` declara a definição com o limiar interpolado (ex.: "Municípios sem atualização de acompanhamento há mais de 30 dias (última atualização registrada); nunca atualizados contam como estagnação máxima.").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server** — `aiToolScope.ts` (extração) + refactor da B185 + `getMunicipalitiesWithoutUpdate.ts` + registro no `index.ts` + seção no `systemPrompt.ts` + unit tests novos (`tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts`):
|
||||
- gate: leader → `{ error: 'Leitura de municípios negada.' }` com payload stub **intocado**; coordinator/advisor passam;
|
||||
- critério: `lastUpdateAt` com 31+ dias sob `days: 30` → incluído; ≤30 dias → excluído; `null` → incluído sempre, no topo; `days: 15` respeitado; ordem: nunca primeiro, depois mais velho→recente;
|
||||
- escopo: "Vale do Jiquiriça" (sem acento) → where `region: 'Vale do Jiquiriçá'`; "Salvador" → where `city: 'Salvador'`; município por nome; escopo inválido → mensagem de erro; sem escopo → where `{}` com `overrideAccess: false, user: ctx.user`;
|
||||
- assessores: nomes populados via segunda query `campaignUser` (`vi.fn().mockResolvedValueOnce` sequencial);
|
||||
- shape: `total` = lista exaustiva; `nuncaAtualizados` correto; `limiarDias`/`criterio` presentes;
|
||||
- regressão: spec `pendingLeadershipsTool.unit.spec.ts` verde sem edição (extração não mudou contrato).
|
||||
2. **Gates** — `pnpm gate:fast` na iteração; sanity manual: `lastUpdateAt` reflete o último update (criar/excluir update na DB local e conferir a coluna); entrega com `pnpm push`.
|
||||
3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar gate genérico HOF nem helper de recência novo (`municipalitySignalAgeInDays` existe — família E9; não duplicar).
|
||||
- Não tocar em `buildCampaignLinks`/`campaignNavigationUrls` (destinos `municipality`/`municipalityList` já existem; sem sort novo na lista).
|
||||
- Não usar `updatedAt` do município nem `votePledge.lastPledgeAt` (o sinal E9 é outra semântica — a intenção B189 fixa só `municipalityUpdate`).
|
||||
- Sem paginação/limite (`truncado`) — contrato exaustivo; sem estatísticas/percentuais.
|
||||
- Não mudar o shape `{ error }` do chat nem o route `api/ai-chat`.
|
||||
- Não editar outras Issues `in-progress`; não tocar migrations (nenhuma).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Extrair o resolver da B185 quebra a tool em produção?** Comportamento idêntico copiado com nomes exportados + spec existente da B185 cobre a regressão sem edição; refactor mecânico.
|
||||
- **Lista exaustiva longa (coordenador, DB esparso):** até ~435 docs de um `find` barato (indexado, select enxuto); o modelo comprime a resposta (prompt instrui: contagem + mais antigos + sugerir estreitar escopo) — contrato de produto mantido (total sempre presente).
|
||||
- **Borda rótulo↔limiar:** eliminada pela partição em JS com dias arredondados (Decisão 2) — teste unitário com 30,4 dias e `days: 30`.
|
||||
- **`lastUpdateAt` defasado na produção:** o hook recomputa em create/update/delete de updates; se algum dia um update for inserido fora do runtime (SQL direto), a coluna pode desatualizar — mesma janela de risco da UI (aceita; a família usa o mesmo campo).
|
||||
- **Salvador resumido pelo modelo:** cobertura de produto via prompt (item ZE com `cidade`); se o modelo falhar em resumir, a lista ZE-a-ZE ainda é exaustiva e correta — aceite não depende do resumo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto (perguntas "quais municípios sem atualização há mais de X dias" no escopo do usuário, contagem total, item por município com "há N dias"/"nunca atualizado" no topo, limiar declarado e ajustável, refinável por região/cidade, agrupável por assessor, links B162, RBAC atual, leader deny fail-closed)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (overrideAccess: false + user; copy pt-BR; identificadores em inglês; sem migration/Consent; lista exaustiva com contagem; "sem atualização" = `municipalityUpdate` via `lastUpdateAt`)
|
||||
- [ ] Testes previstos: unit (gate fail-closed, critério + borda, ordem, escopo acento-tolerante, assessores, shape exaustivo, regressão B185)
|
||||
|
|
@ -45,6 +45,15 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
|
|||
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário; quando trouxer "truncado: true", sugira estreitar o escopo para ver o restante.
|
||||
- Ofereça links de navegação (buildCampaignLinks) para as lideranças citadas (por id) e municípios (por slug); assessores (por id) quando o perfil permitir.
|
||||
|
||||
## Municípios sem atualização recente
|
||||
- Use a ferramenta "getMunicipalitiesWithoutUpdate" para "quais municípios estão sem atualização há mais de X dias", "quais municípios nunca foram atualizados" ou perguntas de cobertura do acompanhamento ("o que está ficando para trás no registro").
|
||||
- A resposta da ferramenta traz o limiar e o critério — declare-os sempre na sua resposta (ex.: "critério: municípios sem atualização de acompanhamento há mais de 30 dias; nunca atualizados contam como estagnação máxima"). Se o usuário pedir outro limiar na pergunta ("há mais de 15 dias?"), passe o valor em "days".
|
||||
- A lista vem ordenada: municípios nunca atualizados primeiro (rotule-os como "nunca atualizado"), depois do mais antigo ao mais recente. Quando a lista for longa, apresente a contagem total e os mais antigos, e sugira estreitar o escopo para ver o restante.
|
||||
- Quando o escopo incluir Salvador, resuma a capital no início da resposta (ex.: "Salvador: 7 das 19 zonas sem atualização 30+ dias") usando a contagem e o campo cidade; detalhe zona a zona quando o usuário pedir.
|
||||
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário.
|
||||
- Se o usuário pedir "agrupa por assessor", agrupe a lista pelos nomes de assessores presentes nos itens.
|
||||
- Ofereça links de navegação (buildCampaignLinks) para os municípios citados (por slug).
|
||||
|
||||
## Contexto eleitoral
|
||||
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
|
||||
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.
|
||||
|
|
|
|||
96
src/utilities/ai/tools/aiToolScope.ts
Normal file
96
src/utilities/ai/tools/aiToolScope.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
type ScopeMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
}
|
||||
|
||||
export type AIToolResolvedScope = {
|
||||
kind: 'regiao' | 'cidade' | 'municipio' | 'todos'
|
||||
name: string | null
|
||||
municipalityIDs: number[] | null
|
||||
municipalities: ScopeMunicipality[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scope resolution of the campaign intelligence tools (family B185/B189;
|
||||
* B186 will be the third consumer): a territory of identity, the virtual city
|
||||
* Salvador (19 ZE together) or a municipality. Extracted from B185 so the
|
||||
* family never twins the "Salvador first, then municipality, then territory"
|
||||
* lookup order.
|
||||
*/
|
||||
export const resolveAIToolScope = async (
|
||||
ctx: AIToolContext,
|
||||
scope: string | undefined,
|
||||
): Promise<AIToolResolvedScope | { error: string }> => {
|
||||
if (!scope) {
|
||||
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
|
||||
}
|
||||
|
||||
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
|
||||
// the catalog has no row literally named "Salvador" — the municipality query
|
||||
// would be a guaranteed-miss round trip.
|
||||
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const resolved = await resolveScopeMunicipalities(ctx, { city: { equals: salvadorCity.city } })
|
||||
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
|
||||
}
|
||||
|
||||
const canonicalMunicipality = resolveMunicipalityName(scope)
|
||||
if (canonicalMunicipality) {
|
||||
const resolved = await resolveScopeMunicipalities(ctx, {
|
||||
name: { equals: canonicalMunicipality },
|
||||
})
|
||||
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
|
||||
}
|
||||
|
||||
const territory = bahiaIdentityTerritories.find(
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
|
||||
)
|
||||
if (territory) {
|
||||
const resolved = await resolveScopeMunicipalities(ctx, { region: { equals: territory } })
|
||||
return { kind: 'regiao', name: territory, ...resolved }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested scope into its municipality set through the actor's
|
||||
* own access (an advisor asking outside the portfolio gets the empty scoped
|
||||
* set — never an existence lie). The generic error stays reserved for names
|
||||
* that match nothing at all.
|
||||
*/
|
||||
const resolveScopeMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
where: Where,
|
||||
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
|
||||
}
|
||||
206
src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts
Normal file
206
src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { tool } from 'ai'
|
||||
import type { Where } from 'payload'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { resolveAIToolScope, type AIToolResolvedScope } from '@/utilities/ai/tools/aiToolScope'
|
||||
import { municipalitySignalAgeInDays } from '@/utilities/municipality/municipalitySignal'
|
||||
|
||||
const DENIED_MESSAGE = 'Leitura de municípios negada.'
|
||||
|
||||
type ScopedMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
kind: string | null
|
||||
lastUpdateAt: string | null
|
||||
advisors: Array<number | string> | null
|
||||
}
|
||||
|
||||
type CoverageItem = {
|
||||
id: number
|
||||
nome: string
|
||||
slug: string
|
||||
tipo: string
|
||||
regiao: string | null
|
||||
cidade: string | null
|
||||
ultimaAtualizacao: string | null
|
||||
diasSemAtualizacao: number | null
|
||||
nuncaAtualizado: boolean
|
||||
assessores: Array<{ id: number; nome: string | null }>
|
||||
}
|
||||
|
||||
/**
|
||||
* B189 — "quais municípios estão sem atualização há mais de X dias" (cobertura
|
||||
* do acompanhamento): varre o escopo do usuário, aplica o limiar (default 30,
|
||||
* ajustável na pergunta) e devolve a lista EXAUSTIVA ordenada do mais velho ao
|
||||
* mais recente, com "nunca atualizado" no topo. Recência vem de
|
||||
* `municipality.lastUpdateAt` (derivado dos hooks de `municipalityUpdate` — a
|
||||
* semântica compartilhada da família B185; nunca `updatedAt` do município).
|
||||
* Staff-only (leader lockdown fail-closed, mesmo shape do gate eleitoral);
|
||||
* leituras com `overrideAccess: false, user: ctx.user` (assessor = portfólio).
|
||||
* Sem migration/Consent; nomes de assessores resolvidos em query manual.
|
||||
*/
|
||||
export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
|
||||
tool({
|
||||
description:
|
||||
'Returns municipalities in scope without a recent follow-up update: last municipalityUpdate ' +
|
||||
'older than N days, or never updated (listed first, stagnation). ' +
|
||||
'Use when the user asks "Quais municípios estão sem atualização há mais de X dias?", ' +
|
||||
'"Quais municípios nunca foram atualizados?" or "Cobertura do acompanhamento em X?". ' +
|
||||
'The threshold is always declared in the response; combine the slugs with buildCampaignLinks.',
|
||||
inputSchema: z.object({
|
||||
scope: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
'Território de identidade, cidade ou município (ex.: "Vale do Jiquiriça", "Salvador", "Feira de Santana"). Omita para usar o escopo de acesso do usuário.',
|
||||
),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(365)
|
||||
.optional()
|
||||
.default(30)
|
||||
.describe(
|
||||
'Limiar em dias: municípios sem atualização há mais de N dias (estritamente maior). Default 30.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ scope, days = 30 }) => {
|
||||
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
|
||||
|
||||
const resolved = await resolveAIToolScope(ctx, scope)
|
||||
if ('error' in resolved) return resolved
|
||||
|
||||
const docs = await loadScopeMunicipalities(ctx, resolved)
|
||||
const items = docs
|
||||
.map(buildCoverageItem)
|
||||
.filter((item) => item.diasSemAtualizacao === null || item.diasSemAtualizacao > days)
|
||||
|
||||
const [neverUpdated, stale] = partitionByStagnation(items)
|
||||
const municipios = [...sortNeverUpdated(neverUpdated), ...sortStale(stale)]
|
||||
|
||||
const advisorIDs = uniqueRelationshipIds(
|
||||
municipios.flatMap((item) => item.assessores.map((a) => a.id)),
|
||||
)
|
||||
const advisorNames = await loadAdvisorNames(ctx, advisorIDs)
|
||||
for (const item of municipios) {
|
||||
item.assessores = item.assessores.map((advisor) => ({
|
||||
id: advisor.id,
|
||||
nome: advisorNames.get(advisor.id) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
escopo: { tipo: resolved.kind, nome: resolved.name },
|
||||
escopoRestrito: ctx.user.role === 'advisor',
|
||||
limiarDias: days,
|
||||
criterio: `Municípios sem atualização de acompanhamento há mais de ${days} dias (última atualização registrada); nunca atualizados contam como estagnação máxima.`,
|
||||
total: municipios.length,
|
||||
nuncaAtualizados: neverUpdated.length,
|
||||
municipios,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const loadScopeMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
scope: AIToolResolvedScope,
|
||||
): Promise<ScopedMunicipality[]> => {
|
||||
const where: Where = scope.municipalityIDs ? { id: { in: scope.municipalityIDs } } : {}
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
city: true,
|
||||
region: true,
|
||||
kind: true,
|
||||
lastUpdateAt: true,
|
||||
advisors: true,
|
||||
},
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
return result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
kind: doc.kind ?? null,
|
||||
lastUpdateAt: doc.lastUpdateAt ?? null,
|
||||
advisors: (Array.isArray(doc.advisors) ? doc.advisors : null) as Array<number | string> | null,
|
||||
}))
|
||||
}
|
||||
|
||||
const buildCoverageItem = (doc: ScopedMunicipality): CoverageItem => ({
|
||||
id: doc.id,
|
||||
nome: doc.name,
|
||||
slug: doc.slug,
|
||||
tipo: doc.kind === 'zona' ? 'Zona eleitoral (Salvador)' : 'Município inteiro',
|
||||
regiao: doc.region,
|
||||
cidade: doc.city,
|
||||
ultimaAtualizacao: doc.lastUpdateAt,
|
||||
diasSemAtualizacao: municipalitySignalAgeInDays(doc.lastUpdateAt),
|
||||
nuncaAtualizado: doc.lastUpdateAt === null,
|
||||
assessores: (Array.isArray(doc.advisors) ? doc.advisors : [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null)
|
||||
.map((id) => ({ id, nome: null })),
|
||||
})
|
||||
|
||||
const partitionByStagnation = (items: CoverageItem[]): [CoverageItem[], CoverageItem[]] => {
|
||||
const neverUpdated: CoverageItem[] = []
|
||||
const stale: CoverageItem[] = []
|
||||
for (const item of items) {
|
||||
if (item.nuncaAtualizado) neverUpdated.push(item)
|
||||
else stale.push(item)
|
||||
}
|
||||
return [neverUpdated, stale]
|
||||
}
|
||||
|
||||
const byName = (left: CoverageItem, right: CoverageItem): number =>
|
||||
left.nome.localeCompare(right.nome, 'pt-BR')
|
||||
|
||||
const sortNeverUpdated = (items: CoverageItem[]): CoverageItem[] => [...items].sort(byName)
|
||||
|
||||
const sortStale = (items: CoverageItem[]): CoverageItem[] =>
|
||||
[...items].sort((left, right) => {
|
||||
const leftAt = new Date(left.ultimaAtualizacao ?? 0).getTime()
|
||||
const rightAt = new Date(right.ultimaAtualizacao ?? 0).getTime()
|
||||
if (leftAt !== rightAt) return leftAt - rightAt
|
||||
return byName(left, right)
|
||||
})
|
||||
|
||||
const loadAdvisorNames = async (
|
||||
ctx: AIToolContext,
|
||||
ids: number[],
|
||||
): Promise<Map<number, string>> => {
|
||||
const names = new Map<number, string>()
|
||||
if (ids.length === 0) return names
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: ids } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
for (const doc of result.docs) names.set(doc.id, doc.name)
|
||||
return names
|
||||
}
|
||||
|
|
@ -3,12 +3,9 @@ import type { Where } from 'payload'
|
|||
import { z } from 'zod'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
|
||||
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
|
||||
import { salvadorCity } from '@/lib/salvadorCity'
|
||||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
import { resolveAIToolScope, type AIToolResolvedScope } from '@/utilities/ai/tools/aiToolScope'
|
||||
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
|
||||
|
||||
const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
|
||||
|
|
@ -18,20 +15,7 @@ const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma lidera
|
|||
const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.'
|
||||
const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const
|
||||
|
||||
type ScopeMunicipality = {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
city: string | null
|
||||
region: string | null
|
||||
}
|
||||
|
||||
type ResolvedScope = {
|
||||
kind: 'regiao' | 'cidade' | 'municipio' | 'todos'
|
||||
name: string | null
|
||||
municipalityIDs: number[] | null
|
||||
municipalities: ScopeMunicipality[]
|
||||
}
|
||||
type ResolvedScope = AIToolResolvedScope
|
||||
|
||||
type PendingLeadershipDoc = {
|
||||
id: number
|
||||
|
|
@ -90,7 +74,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
execute: async ({ scope, filter, mode, limit }) => {
|
||||
if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
|
||||
|
||||
const resolved = await resolveScope(ctx, scope)
|
||||
const resolved = await resolveAIToolScope(ctx, scope)
|
||||
if ('error' in resolved) return resolved
|
||||
|
||||
if (mode === 'municipios_sem_lideranca') {
|
||||
|
|
@ -104,71 +88,6 @@ export const getPendingLeaderships = (ctx: AIToolContext) =>
|
|||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve the requested scope into its municipality set through the actor's
|
||||
* own access (an advisor asking outside the portfolio gets the empty scoped
|
||||
* set — never an existence lie). The generic error stays reserved for names
|
||||
* that match nothing at all.
|
||||
*/
|
||||
const resolveScope = async (
|
||||
ctx: AIToolContext,
|
||||
scope: string | undefined,
|
||||
): Promise<ResolvedScope | { error: string }> => {
|
||||
if (!scope) {
|
||||
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
|
||||
}
|
||||
|
||||
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
|
||||
// the catalog has no row literally named "Salvador" — the municipality query
|
||||
// would be a guaranteed-miss round trip.
|
||||
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
|
||||
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
|
||||
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
|
||||
}
|
||||
|
||||
const canonicalMunicipality = resolveMunicipalityName(scope)
|
||||
if (canonicalMunicipality) {
|
||||
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
|
||||
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
|
||||
}
|
||||
|
||||
const territory = bahiaIdentityTerritories.find(
|
||||
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
|
||||
)
|
||||
if (territory) {
|
||||
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
|
||||
return { kind: 'regiao', name: territory, ...resolved }
|
||||
}
|
||||
|
||||
return {
|
||||
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMunicipalities = async (
|
||||
ctx: AIToolContext,
|
||||
where: Where,
|
||||
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
|
||||
const result = await ctx.payload.find({
|
||||
collection: 'municipality',
|
||||
where,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true, name: true, slug: true, city: true, region: true },
|
||||
overrideAccess: false,
|
||||
user: ctx.user,
|
||||
})
|
||||
const municipalities = result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
slug: doc.slug,
|
||||
city: doc.city ?? null,
|
||||
region: doc.region ?? null,
|
||||
}))
|
||||
return { municipalityIDs: municipalities.map((m) => m.id), municipalities }
|
||||
}
|
||||
|
||||
const listPendingLeaderships = async (
|
||||
ctx: AIToolContext,
|
||||
scope: ResolvedScope,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { calculate } from '@/utilities/ai/tools/calculate'
|
|||
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
|
||||
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
|
||||
import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
|
||||
import { getMunicipalitiesWithoutUpdate } from '@/utilities/ai/tools/getMunicipalitiesWithoutUpdate'
|
||||
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
|
||||
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
|
||||
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
|
||||
|
|
@ -30,6 +31,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
|
|||
getMunicipalityOverview: getMunicipalityOverview(ctx),
|
||||
getLeaderships: getLeaderships(ctx),
|
||||
getPendingLeaderships: getPendingLeaderships(ctx),
|
||||
getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx),
|
||||
getOrganizations: getOrganizations(ctx),
|
||||
getPartnershipCoverage: getPartnershipCoverage(ctx),
|
||||
searchEntities: searchEntities(ctx),
|
||||
|
|
|
|||
276
tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts
Normal file
276
tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import type { Payload } from 'payload'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AIToolContext } from '@/lib/ai/types'
|
||||
import { DAY_MS } from '@/lib/text'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { getMunicipalitiesWithoutUpdate } from '@/utilities/ai/tools/getMunicipalitiesWithoutUpdate'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const denied = { error: 'Leitura de municípios negada.' }
|
||||
|
||||
type FindMock = ReturnType<typeof vi.fn>
|
||||
|
||||
const findResult = (docs: unknown[]) => ({ docs })
|
||||
|
||||
const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
|
||||
|
||||
const leader = stub<CampaignUser>({ collection: 'campaignUser', role: 'leader' })
|
||||
const coordinator = stub<CampaignUser>({ collection: 'campaignUser', role: 'coordinator' })
|
||||
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
|
||||
|
||||
const scriptedPayload = (find: FindMock): Payload =>
|
||||
stub<Payload>({ find: find as Payload['find'] })
|
||||
|
||||
const untouchablePayload = scriptedPayload(
|
||||
vi.fn(() => {
|
||||
throw new Error('gate must fail closed before any payload query')
|
||||
}),
|
||||
)
|
||||
|
||||
const municipalityDoc = (overrides: Record<string, unknown>) => ({
|
||||
id: 1,
|
||||
name: 'Amargosa',
|
||||
slug: 'amargosa',
|
||||
city: 'Amargosa',
|
||||
region: 'Vale do Jiquiriçá',
|
||||
kind: 'municipio',
|
||||
lastUpdateAt: null,
|
||||
advisors: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const daysAgo = (days: number): string => new Date(Date.now() - days * DAY_MS).toISOString()
|
||||
|
||||
type ExecutableTool = {
|
||||
execute: (args: unknown, options?: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
const execute =
|
||||
(payload: Payload, user: CampaignUser = coordinator) =>
|
||||
(args: unknown) =>
|
||||
(getMunicipalitiesWithoutUpdate(ctxFor(user, payload)) as unknown as ExecutableTool).execute!(
|
||||
args,
|
||||
)
|
||||
|
||||
const run = async (
|
||||
find: FindMock,
|
||||
args: unknown,
|
||||
user: CampaignUser = coordinator,
|
||||
): Promise<Record<string, unknown>> =>
|
||||
(await execute(scriptedPayload(find), user)(args)) as Record<string, unknown>
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate gate (B189)', () => {
|
||||
it('denies a leader with the chat-shaped error before any payload query', async () => {
|
||||
const payload = untouchablePayload
|
||||
await expect(execute(payload, leader)({})).resolves.toEqual(denied)
|
||||
expect(payload.find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a coordinator pass and returns the empty staff shape', async () => {
|
||||
const find = vi.fn().mockResolvedValue(findResult([]))
|
||||
const result = await run(find, {})
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.municipios).toEqual([])
|
||||
expect(result.escopoRestrito).toBe(false)
|
||||
expect(result.limiarDias).toBe(30)
|
||||
expect(result.criterio).toContain('há mais de 30 dias')
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: 'municipality',
|
||||
where: {},
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
|
||||
const find = vi.fn().mockResolvedValue(findResult([]))
|
||||
const result = await run(find, {}, advisor)
|
||||
expect(result.total).toBe(0)
|
||||
expect(result.escopoRestrito).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate coverage criterion (B189)', () => {
|
||||
it('includes never-updated (top) and municipalities older than the threshold; excludes fresher ones', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: null, name: 'Nunca' }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: daysAgo(40), name: 'Velho' }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(31), name: 'Borda incluída' }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: daysAgo(30), name: 'Borda excluída' }),
|
||||
municipalityDoc({ id: 5, lastUpdateAt: daysAgo(5), name: 'Fresco' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const ids = (result.municipios as Array<{ id: number }>).map((m) => m.id)
|
||||
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.nuncaAtualizados).toBe(1)
|
||||
expect(ids).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('respects a custom threshold from the question (strictly greater)', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: daysAgo(20) }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: daysAgo(16) }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(15) }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: daysAgo(14) }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, { days: 15 })
|
||||
const ids = (result.municipios as Array<{ id: number }>).map((m) => m.id)
|
||||
expect(ids).toEqual([1, 2])
|
||||
expect(result.limiarDias).toBe(15)
|
||||
expect(result.criterio).toContain('há mais de 15 dias')
|
||||
})
|
||||
|
||||
it('orders never-updated first (by name), then oldest to newest (by name as tie-break)', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({ id: 1, lastUpdateAt: daysAgo(40), name: 'Beta' }),
|
||||
municipalityDoc({ id: 2, lastUpdateAt: null, name: 'Zulu' }),
|
||||
municipalityDoc({ id: 3, lastUpdateAt: daysAgo(40), name: 'Alpha' }),
|
||||
municipalityDoc({ id: 4, lastUpdateAt: null, name: 'Mike' }),
|
||||
municipalityDoc({ id: 5, lastUpdateAt: daysAgo(32), name: 'Gama' }),
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const names = (result.municipios as Array<{ nome: string }>).map((m) => m.nome)
|
||||
expect(names).toEqual(['Mike', 'Zulu', 'Alpha', 'Beta', 'Gama'])
|
||||
})
|
||||
|
||||
it('labels never-updated items with null days and the stagnation flag', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
findResult([municipalityDoc({ id: 1, lastUpdateAt: null, name: 'Nunca' })]),
|
||||
)
|
||||
|
||||
const result = await run(find, {})
|
||||
const item = (result.municipios as Array<Record<string, unknown>>)[0]!
|
||||
expect(item.diasSemAtualizacao).toBeNull()
|
||||
expect(item.nuncaAtualizado).toBe(true)
|
||||
expect(item.ultimaAtualizacao).toBeNull()
|
||||
expect(item.tipo).toBe('Município inteiro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate scope resolution (B189)', () => {
|
||||
it('resolves a region scope without accents into the region where', async () => {
|
||||
const find = vi
|
||||
.fn()
|
||||
.mockResolvedValue(findResult([municipalityDoc({ id: 1, region: 'Vale do Jiquiriçá' })]))
|
||||
const result = await run(find, { scope: 'Vale do Jiquiriça' })
|
||||
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { region: { equals: 'Vale do Jiquiriçá' } },
|
||||
}),
|
||||
)
|
||||
expect(find).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [1] } } }))
|
||||
})
|
||||
|
||||
it('resolves "Salvador" into the 19 zones as one city scope', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([municipalityDoc({ id: 1 }), municipalityDoc({ id: 2, name: 'Salvador ZE 2' })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
const result = await run(find, { scope: 'Salvador' })
|
||||
expect(result.escopo).toEqual({ tipo: 'cidade', nome: 'Salvador (cidade)' })
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ where: { city: { equals: 'Salvador' } } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a municipality by name', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1 })]))
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
const result = await run(find, { scope: 'amargosa' })
|
||||
expect(result.escopo).toEqual({ tipo: 'municipio', nome: 'Amargosa' })
|
||||
})
|
||||
|
||||
it('rejects an unknown scope with the family error message', async () => {
|
||||
const find = vi.fn()
|
||||
const result = await run(find, { scope: 'Atlântida' })
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Escopo não reconhecido') })
|
||||
expect(find).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters the scope query by municipality ids when a scope resolved them', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1 }), municipalityDoc({ id: 2 })]))
|
||||
find.mockResolvedValueOnce(findResult([]))
|
||||
await run(find, { scope: 'Salvador' }, advisor)
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { id: { in: [1, 2] } },
|
||||
overrideAccess: false,
|
||||
user: advisor,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMunicipalitiesWithoutUpdate advisors (B189)', () => {
|
||||
it('resolves advisor names via a second campaignUser query and attaches them to items', async () => {
|
||||
const find = vi.fn()
|
||||
find.mockResolvedValueOnce(
|
||||
findResult([municipalityDoc({ id: 1, advisors: [41, 42] }), municipalityDoc({ id: 2 })]),
|
||||
)
|
||||
find.mockResolvedValueOnce(findResult([{ id: 41, name: 'Ana Assessora' }]))
|
||||
|
||||
const result = await run(find, {})
|
||||
const items = result.municipios as Array<{
|
||||
assessores: Array<{ id: number; nome: string | null }>
|
||||
}>
|
||||
expect(items[0]!.assessores).toEqual([
|
||||
{ id: 41, nome: 'Ana Assessora' },
|
||||
{ id: 42, nome: null },
|
||||
])
|
||||
expect(items[1]!.assessores).toEqual([])
|
||||
expect(find).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: [41, 42] } },
|
||||
select: { name: true },
|
||||
overrideAccess: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps zone items typed as electoral zones', async () => {
|
||||
const find = vi.fn().mockResolvedValue(
|
||||
findResult([
|
||||
municipalityDoc({
|
||||
id: 1,
|
||||
kind: 'zona',
|
||||
name: 'Salvador ZE 3',
|
||||
city: 'Salvador',
|
||||
region: 'Metropolitano de Salvador',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
const result = await run(find, {})
|
||||
const item = (result.municipios as Array<Record<string, unknown>>)[0]!
|
||||
expect(item.tipo).toBe('Zona eleitoral (Salvador)')
|
||||
expect(item.cidade).toBe('Salvador')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue