diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f5e3a7c8..17678aaf 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 `
`:** 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). diff --git a/docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md b/docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md new file mode 100644 index 00000000..2bd44509 --- /dev/null +++ b/docs/plans/sollinha-municipios-sem-atualizacao-recente-impl.md @@ -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
leader → { error }] + G --> S[resolveAIToolScope
município → Salvador → região] + S --> Q1[find municipality
escopo + access do usuário
select lastUpdateAt/advisors/…] + Q1 --> F[idade = ageInDays(lastUpdateAt)
inclui null sempre; > days?] + F --> Q2[find campaignUser
nomes dos assessores] + Q2 --> R[{ escopo, limiarDias, criterio,
total, nuncaAtualizados, municipios }] + R --> L[buildCampaignLinks
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) diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 38fb842f..c8e77dce 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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. diff --git a/src/utilities/ai/tools/aiToolScope.ts b/src/utilities/ai/tools/aiToolScope.ts new file mode 100644 index 00000000..291236ce --- /dev/null +++ b/src/utilities/ai/tools/aiToolScope.ts @@ -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 => { + 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 } +} diff --git a/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts b/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts new file mode 100644 index 00000000..61e37f38 --- /dev/null +++ b/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts @@ -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 | 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 => { + 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 | 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> => { + const names = new Map() + 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 +} diff --git a/src/utilities/ai/tools/getPendingLeaderships.ts b/src/utilities/ai/tools/getPendingLeaderships.ts index 75ffae6c..d22ef1a4 100644 --- a/src/utilities/ai/tools/getPendingLeaderships.ts +++ b/src/utilities/ai/tools/getPendingLeaderships.ts @@ -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 => { - 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, diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index 029e4fd3..c38674c9 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), diff --git a/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts b/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts new file mode 100644 index 00000000..5f386e71 --- /dev/null +++ b/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts @@ -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 + +const findResult = (docs: unknown[]) => ({ docs }) + +const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload }) + +const leader = stub({ collection: 'campaignUser', role: 'leader' }) +const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' }) +const advisor = stub({ collection: 'campaignUser', role: 'advisor' }) + +const scriptedPayload = (find: FindMock): Payload => + stub({ 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) => ({ + 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 +} + +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> => + (await execute(scriptedPayload(find), user)(args)) as Record + +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>)[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>)[0]! + expect(item.tipo).toBe('Zona eleitoral (Salvador)') + expect(item.cidade).toBe('Salvador') + }) +})