diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index c1a671ea..a520a8c9 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):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
+
**Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).
**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. \*\*Pe... (line truncated to 2000 chars)
diff --git a/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md b/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md
new file mode 100644
index 00000000..10a19ec1
--- /dev/null
+++ b/docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md
@@ -0,0 +1,96 @@
+# Impl: Sollinha — lideranças pendentes de abordagem + assessores responsáveis (gestão)
+
+Status: aprovado
+Atualizado em: 2026-08-10
+Issue: #524
+Intenção: docs/plans/sollinha-liderancas-pendentes-abordagem.md
+Appetite restante: ~1 dia eng (herdado); sem migration / Consent / collection / UI nova
+
+## Leitura da intenção
+
+- **Outcome:** uma tool nova do Sollinha responde "quais lideranças ainda precisam ser abordadas" por território de identidade, cidade (Salvador = 19 ZE) ou município — com o **critério de pendência declarado na resposta**, responsáveis (assessores) à vista, links de navegação via `buildCampaignLinks`, RBAC atual (assessor = portfólio; leader = deny fail-closed), filtro "sem assessor" (C3) e modo "municípios sem liderança" (C4).
+- **O que NÃO negociar:** leader lockdown (liderança não tem essa conversa — deny antes de qualquer query); assessor vê só o portfólio (RBAC atual via access control da collection); sem dados eleitorais na resposta; sem write tool; critério sempre visível; top N com total.
+- **O que reavaliar:** a redação literal da semântica compartilhada ("status ≠ `engajado` ou sem `votePledge`") incluiria `negativo` na lista — o aceite da intenção nomeia o eixo de status como "'a abordar'/'em disputa'", e produto não aborda quem é negativo. **Corte desta engenharia: `negativo` nunca entra** (ver Decisões). Também reavaliado: a hipótese "fields: supportStatus/updatedAt/advisors, votePledge.declaredAt" — o eixo do compromisso é a **presença** de `votePledge` (leadership×municipality, unique), não `declaredAt`; recência é semântica da B186, não desta.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ P[ai-chat route] --> B[buildAITools]
+ B --> T[getPendingLeaderships ctx]
+ T --> G[gate staff inline
leader → { error }]
+ G --> S[resolve escopo
município → cidade → região]
+ S --> Q1[find municipality
região/cidade → ids]
+ S --> Q2[count+find leadership
status in + escopo + sem_assessor]
+ Q2 --> Q3[find votePledge
lideranças × escopo → set]
+ Q3 --> F[pendente = status≠engajado
ou sem pledge]
+ F --> Q4[find campaignUser
nomes dos assessores]
+ F --> R[{ escopo, criterio, total,
liderancas, truncado }]
+ R --> L[buildCampaignLinks
liderança/município/assessor]
+```
+
+**Opções consideradas:**
+
+- **A — Tool única `getPendingLeaderships`** com `modo` (lideranças | municípios sem liderança) e `filtro` (sem assessor), escopo livre região|cidade|município.
+- **B — Duas tools separadas** (uma de lideranças pendentes, uma de municípios sem liderança).
+- **C — Estender `getLeaderships`** com parâmetros de pendência.
+
+**Recomendação: A** — a intenção foldou o C4 na mesma tool (decisão de produto já tomada: "mesma tool, mesma família de dado, aceite único 'saber o que falta no território'"); um `modo` discriminado mantém o surface enxuto (1 entrada no index, 1 descrição para o modelo) e o C4 é um modo de saída sobre o mesmo escopo. **Rejeitadas:** B (2 tools para o mesmo aceite; o modelo precisaria decidir qual chamar, e a resposta de C4 semânticamente "não tem liderança" é a mesma leitura de lacuna), C (mudaria o contrato da tool mais usada da família e o critério de pendência é default-ON, não um filtro opcional — poluiria as respostas de "quais lideranças temos em X").
+
+### Componentes / mudanças
+
+- **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`, novo): factory `tool()` com input Zod; gate staff inline no topo do `execute` (1 call site — regra 2=inline, 3+=extrair; não criar helper); resolução de escopo pura local (ordem: município → cidade Salvador → território, tolerante a acento); queries todas `overrideAccess: false, user: ctx.user` (padrão da família — assessor auto-scope, sem `estimatedVotes`).
+- **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getPendingLeaderships: getPendingLeaderships(ctx)`.
+- **`systemPrompt.ts`** (`src/utilities/ai/`): seção curta "Lideranças pendentes" — quando usar a tool (perguntas de "o que falta abordar", "sem assessor", "municípios sem liderança"), obrigatório **declarar o critério devolvido pela tool** na resposta e oferecer links via `buildCampaignLinks` (liderança por id, município por slug, assessor por id quando unrestricted).
+- **Migration:** sem migration.
+- **Access / Consent:** nenhuma chave nova; gate fail-closed via `isStaffCampaignRole(ctx.user.role)`; leituras delegadas ao access control das collections (`canReadLeadership`/`canReadVotePledge`/`canReadMunicipality`/`canReadCampaignUsers`) — 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 em texto no chat** (nome, municípios, status, última atualização, assessores) com total e truncamento top N — a intenção fixa "lista, não mapa; nunca % estadual; critério sempre visível". Rejeitadas: agregado por assessor na própria tool (o modelo agrupa pela lista — mesma leitura, sem modo de saída extra), estatística/percentuais (anti-goal).
+
+## Decisões de engenharia
+
+1. **Critério de pendência.** `pendente = status ∈ {a_abordar, em_disputa} OU (status = engajado E sem votePledge no escopo consultado)`. O eixo status é pré-filtrado no `where` (`status: { in: [a_abordar, em_disputa, engajado] }`) — `negativo` é excluído na query (fail-closed, não pode "vazar" por bug de JS). Compromisso = presença de `votePledge` (liderança×município, unique) com município no escopo; para escopo território/cidade, **qualquer** pledge dentro do escopo satisfaz o eixo (liderança tem compromisso no território). Critério declarado na resposta: `'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.'`
+ - **Rejeitadas:** (a) `declaredAt` recente no critério — recência é semântica da B186 (prioridades), que reusa a definição desta família; (b) exigir pledge em **todos** os municípios da liderança — para um território com 20 municípios isso tornaria quase toda liderança pendente e a resposta perderia a função de priorização; (c) incluir `negativo` — contradiz o aceite (só a_abordar/em_disputa) e o significado operacional do status.
+2. **Gate.** Inline na tool (1 call site): `if (!isStaffCampaignRole(ctx.user.role)) return { error: 'Leitura de dados de lideranças negada.' }`. **Rejeitada:** extrair helper compartilhado estilo `electionDataGate` (regra 2=inline/3+=extrair; nenhum outro domínio staff-only de gestão existe ainda). Mensagem no shape do chat da B180.
+3. **Resolução de escopo.** Ordem: (1) `resolveMunicipalityName` (aliases + acento, já canônico); (2) `normalizeSearchPhrase('salvador')` → cidade (19 ZE via `city: { equals: 'Salvador' }`); (3) território por `normalizeSearchPhrase` sobre `bahiaIdentityTerritories` (ex.: "Vale do Jiquiriça" sem acento → 'Vale do Jiquiriçá'); (4) erro `'Escopo não reconhecido: "X". Use um município, cidade ou território de identidade da Bahia.'` Sem escopo → escopo do usuário (coordenador = estado; assessor = portfólio via access control). **Rejeitada:** deixar o Payload resolver com `like` — não faz folding de acento e vazaria matches parciais.
+4. **Rótulos de status.** Reuso de `supportStatusLabels` (`src/utilities/leadership/leadershipLabels.ts`, fonte canônica da UI) — **não** do `SUPPORT_LABELS` local de `getLeaderships.ts` (duplicata existente; esta tool não a propaga).
+5. **`filtro: 'sem_assessor'` (C3).** `advisors: { exists: false }` no `where` (precedente do repo: `revokedAt`, `readAt`, `municipality`). **Verificação obrigatória na implementação:** `exists: false` em hasMany (join table) — validar contra a DB local com query real; fallback documentado: fetch sem o filtro + filtro JS + `count` com o mesmo where seria inconsistente, então o fallback real seria filtrar em JS e ajustar `total` pelo conjunto filtrado (nunca usar `count` com where diferente do find).
+6. **Top N + total.** `count` + `find` com o mesmo `where` (sincronia garantida); `limit` default 20, max 50, sort `-updatedAt` (precedente `getLeaderships`); resposta com `total` e `truncado: true` + hint de estreitar escopo quando truncar. **Adiado (barato):** ordenação por "mais antigo primeiro" para pendentes — não muda aceite, revisitável com B186.
+
+## Fases verificáveis
+
+1. **Tracer / server** — `getPendingLeaderships.ts` + registro no `index.ts` + seção no `systemPrompt.ts` + unit tests novos (`tests/unit/pendingLeadershipsTool.unit.spec.ts`):
+ - gate: leader → `{ error: 'Leitura de dados de lideranças negada.' }` com payload stub **intocado** (fail-closed antes de qualquer query); coordinator/advisor passam;
+ - critério: a_abordar sem pledge → incluída; em_disputa → incluída; engajado + pledge no escopo → excluída; engajado sem pledge → incluída; **negativo nunca entra** (assert no `where` do stub: `status in [a_abordar, em_disputa, engajado]`); pledge fora do escopo não satisfaz o eixo;
+ - 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;
+ - C3: `where` contém `advisors: { exists: false }`;
+ - C4: municípios do escopo menos os cobertos por liderança (stub com docs de liderança multi-municípios); resposta declara o critério;
+ - assessores: nomes populados via segunda query `campaignUser` (stub de `find` sequencial com `vi.fn().mockResolvedValueOnce`).
+2. **Gates** — `pnpm gate:fast` na iteração; verificação local do `exists: false` em hasMany; 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 compartilhado (1 call site).
+- Não mexer em `getLeaderships` (incl. não "consertar" o `SUPPORT_LABELS` duplicado — fora do escopo, nota de simplify fica no triage).
+- Não tocar em `buildCampaignLinks`/`campaignNavigationUrls` — os destinos `leadership`/`municipality`/`advisor`/`municipalityList`/`leadershipList` já existem.
+- Não usar `declaredAt`/recência no critério (B186), nem `estimatedVotes` (leader nunca vê; aqui nem aparece).
+- Sem paginação por offset (top N + escopo menor é o contrato da intenção); sem estatísticas/percentuais.
+- Não mudar o shape `{ error }` do chat nem o route `api/ai-chat`.
+
+## Riscos e mitigação
+
+- **`exists: false` em hasMany `advisors`:** pode não ser suportado como esperado — mitigado pela verificação local obrigatória na fase 1 (fallback JS documentado na Decisão 5).
+- **Stub multi-call nos unit tests:** tools fazem 3–5 `payload.find/count` — mitigado com `vi.fn().mockResolvedValueOnce` em sequência e asserts por chamada (index ou args do where).
+- **Escopo de assessor fora do portfólio:** perguntar por território que não administra devolve lista vazia/escopada (RBAC atual, fail-closed) — a resposta declara o escopo efetivo; teste com advisor + escopo fora cobre o shape vazio.
+- **Divergência de critério vs texto da intenção:** o corte de `negativo` é a única mudança de produto — apresentada neste gate antes de executar.
+- **Flake `testDatabaseLease.int.spec.ts`:** fora de escopo (triage conhecido); reavaliar se recorrer.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto (perguntas de pendentes por região/cidade/município com critério declarado; assessores com link; C3; C4; RBAC atual; leader deny fail-closed)
+- [ ] Invariantes AGENTS/engineering-standards (overrideAccess: false + user; copy pt-BR; identificadores em inglês; sem migration/Consent; top N com total)
+- [ ] Testes previstos: unit (gate, critério, escopo tolerante a acento, C3, C4, nomes de assessores, where assert)
diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts
index 64fcbd82..e38c30a3 100644
--- a/src/utilities/ai/systemPrompt.ts
+++ b/src/utilities/ai/systemPrompt.ts
@@ -38,6 +38,12 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Quando a ferramenta devolver zero cidades / "sem dados", diga claramente que não há dados para aquele ano/candidato — nunca invente número.
- Ofereça link de navegação (buildCampaignLinks) para os municípios citados quando útil (entidade singular/concreta).
+## Lideranças pendentes de abordagem
+- Use a ferramenta "getPendingLeaderships" para "quais lideranças ainda precisam ser abordadas em X", "lideranças sem assessor responsável" ou "quais municípios não têm liderança cadastrada".
+- A resposta da ferramenta traz o critério de pendência — declare-o sempre na sua resposta (ex.: "critério: status 'A abordar' ou 'Em disputa'; ou 'Engajado' sem compromisso de votos no escopo consultado").
+- Escopos: território de identidade (aceita grafia sem acento, ex.: "Vale do Jiquiriça"), cidade ("Salvador" = as 19 zonas eleitorais juntas) ou município. Sem escopo, vale o escopo de acesso do usuário.
+- 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.
+
## 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/getPendingLeaderships.ts b/src/utilities/ai/tools/getPendingLeaderships.ts
new file mode 100644
index 00000000..15f95071
--- /dev/null
+++ b/src/utilities/ai/tools/getPendingLeaderships.ts
@@ -0,0 +1,437 @@
+import { tool } from 'ai'
+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 } from '@/lib/relationship'
+import { salvadorCity } from '@/lib/salvadorCity'
+import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
+import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
+
+const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
+const PENDING_CRITERION =
+ 'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.'
+const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma liderança vinculada.'
+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[]
+}
+
+/**
+ * B185 — "quais lideranças ainda precisam ser abordadas" por território,
+ * cidade (Salvador = 19 ZE) ou município, com critério declarado e assessores
+ * responsáveis à vista. 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 resolvidos em queries
+ * manuais (nunca `depth` populando `campaignUser`).
+ */
+export const getPendingLeaderships = (ctx: AIToolContext) =>
+ tool({
+ description:
+ 'Returns campaign leaderships still pending approach within a scope (territory, city or municipality), ' +
+ 'or the municipalities without any linked leadership. ' +
+ 'Use when the user asks "Quais lideranças ainda precisamos abordar em X?", ' +
+ '"Quais lideranças estão sem assessor responsável?" or "Quais municípios não têm liderança cadastrada?". ' +
+ 'The pending criterion is always declared in the response; combine the ids/slugs with buildCampaignLinks.',
+ inputSchema: z.object({
+ escopo: 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.',
+ ),
+ filtro: z
+ .enum(['sem_assessor'])
+ .optional()
+ .describe('Restringe a lista de lideranças às que não têm assessor responsável.'),
+ modo: z
+ .enum(['liderancas', 'municipios_sem_lideranca'])
+ .optional()
+ .default('liderancas')
+ .describe(
+ '"liderancas" lista as pendentes de abordagem; "municipios_sem_lideranca" lista os municípios do escopo sem nenhuma liderança vinculada.',
+ ),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .max(50)
+ .optional()
+ .default(20)
+ .describe('Máximo de resultados (default 20, max 50).'),
+ }),
+ execute: async ({ escopo, filtro, modo, limit }) => {
+ if (!isStaffCampaignRole(ctx.user.role)) return { error: DENIED_MESSAGE }
+
+ const scope = await resolveScope(ctx, escopo)
+ if ('error' in scope) return scope
+
+ if (modo === 'municipios_sem_lideranca') {
+ if (filtro) {
+ return { error: 'O filtro "sem assessor" só se aplica à lista de lideranças.' }
+ }
+ return listMunicipalitiesWithoutLeadership(ctx, scope, limit)
+ }
+
+ return listPendingLeaderships(ctx, scope, filtro === 'sem_assessor', limit)
+ },
+ })
+
+const resolveScope = async (
+ ctx: AIToolContext,
+ escopo: string | undefined,
+): Promise => {
+ if (!escopo) {
+ return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
+ }
+
+ const canonicalMunicipality = resolveMunicipalityName(escopo)
+ if (canonicalMunicipality) {
+ const result = await ctx.payload.find({
+ collection: 'municipality',
+ where: { name: { equals: canonicalMunicipality } },
+ depth: 0,
+ limit: 1,
+ pagination: false,
+ select: { id: true, name: true, slug: true, city: true, region: true },
+ overrideAccess: false,
+ user: ctx.user,
+ })
+ const doc = result.docs[0]
+ if (doc) {
+ return {
+ kind: 'municipio',
+ name: doc.name,
+ municipalityIDs: [doc.id],
+ municipalities: [
+ {
+ id: doc.id,
+ name: doc.name,
+ slug: doc.slug,
+ city: doc.city ?? null,
+ region: doc.region ?? null,
+ },
+ ],
+ }
+ }
+ }
+
+ if (normalizeSearchPhrase(escopo) === normalizeSearchPhrase(salvadorCity.city)) {
+ const result = await ctx.payload.find({
+ collection: 'municipality',
+ where: { city: { equals: salvadorCity.city } },
+ 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,
+ }))
+ if (municipalities.length === 0) {
+ return { error: `Nenhuma zona eleitoral encontrada para "${escopo}".` }
+ }
+ return {
+ kind: 'cidade',
+ name: salvadorCity.name,
+ municipalityIDs: municipalities.map((m) => m.id),
+ municipalities,
+ }
+ }
+
+ const territory = bahiaIdentityTerritories.find(
+ (name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(escopo),
+ )
+ if (territory) {
+ const result = await ctx.payload.find({
+ collection: 'municipality',
+ where: { region: { equals: territory } },
+ 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,
+ }))
+ if (municipalities.length === 0) {
+ return { error: `Nenhum município encontrado no território "${territory}".` }
+ }
+ return {
+ kind: 'regiao',
+ name: territory,
+ municipalityIDs: municipalities.map((m) => m.id),
+ municipalities,
+ }
+ }
+
+ return {
+ error: `Escopo não reconhecido: "${escopo}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
+ }
+}
+
+const listPendingLeaderships = async (
+ ctx: AIToolContext,
+ scope: ResolvedScope,
+ semAssessor: boolean,
+ limit: number,
+) => {
+ const { payload } = ctx
+
+ const andClauses: Where[] = [{ supportStatus: { in: [...PENDING_SUPPORT_STATUSES] } }]
+ if (scope.municipalityIDs) andClauses.push({ municipalities: { in: scope.municipalityIDs } })
+ if (semAssessor) andClauses.push({ advisors: { exists: false } })
+ const where: Where = { and: andClauses }
+
+ const leaderships = await payload.find({
+ collection: 'leadership',
+ where,
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: {
+ id: true,
+ contact: true,
+ municipalities: true,
+ supportStatus: true,
+ advisors: true,
+ updatedAt: true,
+ },
+ sort: '-updatedAt',
+ overrideAccess: false,
+ user: ctx.user,
+ })
+
+ const docs = leaderships.docs as unknown as Array<{
+ id: number
+ contact: number | string | null
+ municipalities: Array | null
+ supportStatus: string
+ advisors: Array | null
+ updatedAt: string | null
+ }>
+
+ const leadershipIDs = docs.map((doc) => doc.id)
+
+ const pledgeAndClauses: Where[] = [{ leadership: { in: leadershipIDs } }]
+ if (scope.municipalityIDs) {
+ pledgeAndClauses.push({ municipality: { in: scope.municipalityIDs } })
+ }
+ const pledgedLeadershipIDs = new Set()
+ if (leadershipIDs.length > 0) {
+ const pledges = await payload.find({
+ collection: 'votePledge',
+ where: { and: pledgeAndClauses },
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { leadership: true },
+ overrideAccess: false,
+ user: ctx.user,
+ })
+ for (const pledge of pledges.docs) {
+ const id = relationshipId(pledge.leadership)
+ if (id !== null) pledgedLeadershipIDs.add(id)
+ }
+ }
+
+ const pending = docs.filter((doc) => {
+ if (doc.supportStatus === 'a_abordar' || doc.supportStatus === 'em_disputa') return true
+ if (doc.supportStatus === 'engajado') return !pledgedLeadershipIDs.has(doc.id)
+ return false
+ })
+
+ const total = pending.length
+ const top = pending.slice(0, limit)
+
+ const contactIDs = uniqueIDs(top.flatMap((doc) => [doc.contact]))
+ const municipalityIDs = uniqueIDs(top.flatMap((doc) => doc.municipalities ?? []))
+ const advisorIDs = uniqueIDs(top.flatMap((doc) => doc.advisors ?? []))
+
+ const [contactNames, municipalityNames, advisorNames] = await Promise.all([
+ loadNamesByIds(ctx, 'contact', contactIDs),
+ loadMunicipalityNamesByIds(ctx, municipalityIDs),
+ loadNamesByIds(ctx, 'campaignUser', advisorIDs),
+ ])
+
+ const liderancas = top.map((doc) => {
+ const contactID = relationshipId(doc.contact)
+ const municipalities = (doc.municipalities ?? [])
+ .map(relationshipId)
+ .filter((id): id is number => id !== null)
+ const advisors = (doc.advisors ?? [])
+ .map(relationshipId)
+ .filter((id): id is number => id !== null)
+ return {
+ id: doc.id,
+ nome: contactNames.get(contactID ?? -1) ?? 'Sem nome',
+ municipios: municipalities.map((id) => ({
+ id,
+ nome: municipalityNames.get(id)?.name ?? null,
+ slug: municipalityNames.get(id)?.slug ?? null,
+ })),
+ status:
+ supportStatusLabels[doc.supportStatus as keyof typeof supportStatusLabels] ??
+ doc.supportStatus,
+ assessores: advisors.map((id) => ({ id, nome: advisorNames.get(id) ?? null })),
+ ultimaAtualizacao: doc.updatedAt ?? null,
+ }
+ })
+
+ return {
+ escopo: { tipo: scope.kind, nome: scope.name },
+ criterio: PENDING_CRITERION,
+ filtroAplicado: { semAssessor: semAssessor },
+ total,
+ liderancas,
+ truncado: total > top.length,
+ }
+}
+
+const listMunicipalitiesWithoutLeadership = async (
+ ctx: AIToolContext,
+ scope: ResolvedScope,
+ limit: number,
+) => {
+ const { payload } = ctx
+
+ let scopeDocs = scope.municipalities
+ if (scope.municipalityIDs === null) {
+ const result = await 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,
+ })
+ scopeDocs = result.docs.map((doc) => ({
+ id: doc.id,
+ name: doc.name,
+ slug: doc.slug,
+ city: doc.city ?? null,
+ region: doc.region ?? null,
+ }))
+ }
+
+ const scopeIDs = scopeDocs.map((doc) => doc.id)
+
+ const covered = await payload.find({
+ collection: 'leadership',
+ where: { municipalities: { in: scopeIDs } },
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ select: { municipalities: true },
+ overrideAccess: false,
+ user: ctx.user,
+ })
+
+ const coveredIDs = new Set()
+ for (const doc of covered.docs) {
+ for (const id of (Array.isArray(doc.municipalities) ? doc.municipalities : []).map(
+ relationshipId,
+ )) {
+ if (id !== null) coveredIDs.add(id)
+ }
+ }
+
+ const missing = scopeDocs.filter((doc) => !coveredIDs.has(doc.id))
+ const total = missing.length
+ const shown = missing.slice(0, limit)
+
+ return {
+ escopo: { tipo: scope.kind, nome: scope.name },
+ criterio: EMPTY_MUNICIPALITIES_CRITERION,
+ total,
+ municipios: shown.map((doc) => ({
+ id: doc.id,
+ nome: doc.name,
+ slug: doc.slug,
+ regiao: doc.region,
+ cidade: doc.city,
+ })),
+ truncado: total > shown.length,
+ }
+}
+
+const uniqueIDs = (values: Array): number[] => {
+ const ids: number[] = []
+ for (const value of values) {
+ const id = relationshipId(value)
+ if (id !== null && !ids.includes(id)) ids.push(id)
+ }
+ return ids
+}
+
+const loadNamesByIds = async (
+ ctx: AIToolContext,
+ collection: 'contact' | 'campaignUser',
+ ids: number[],
+): Promise