feat(B185): tool do Sollinha para lideranças pendentes de abordagem + assessores responsáveis

getPendingLeaderships: escopo por território/cidade(Salvador=19ZE)/município com
folding de acento, critério de pendência declarado (status A abordar/Em disputa
ou Engajado sem votePledge no escopo; negativo excluído na query), filtro
sem_assessor (C3), modo municipios_sem_lideranca (C4), nomes de assessores via
query própria, gate staff fail-closed (leader deny antes de qualquer query).
Sem migration/Consent/UI; unit tests 14 novos; allowlist P3-D justificada.
This commit is contained in:
Francisco Solla 2026-08-10 02:19:55 -03:00
parent 59022b6f77
commit 03a2f57d03
7 changed files with 924 additions and 0 deletions

View file

@ -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)

View file

@ -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<br/>leader → { error }]
G --> S[resolve escopo<br/>município → cidade → região]
S --> Q1[find municipality<br/>região/cidade → ids]
S --> Q2[count+find leadership<br/>status in + escopo + sem_assessor]
Q2 --> Q3[find votePledge<br/>lideranças × escopo → set]
Q3 --> F[pendente = status≠engajado<br/>ou sem pledge]
F --> Q4[find campaignUser<br/>nomes dos assessores]
F --> R[{ escopo, criterio, total,<br/>liderancas, truncado }]
R --> L[buildCampaignLinks<br/>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)

View file

@ -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.

View file

@ -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<ResolvedScope | { error: string }> => {
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<number | string> | null
supportStatus: string
advisors: Array<number | string> | 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<number>()
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<number>()
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 | string | null>): 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<Map<number, string>> => {
const names = new Map<number, string>()
if (ids.length === 0) return names
const result = await ctx.payload.find({
collection,
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
}
const loadMunicipalityNamesByIds = async (
ctx: AIToolContext,
ids: number[],
): Promise<Map<number, { name: string | null; slug: string | null }>> => {
const names = new Map<number, { name: string | null; slug: string | null }>()
if (ids.length === 0) return names
const result = await ctx.payload.find({
collection: 'municipality',
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
select: { id: true, name: true, slug: true },
overrideAccess: false,
user: ctx.user,
})
for (const doc of result.docs) names.set(doc.id, { name: doc.name, slug: doc.slug })
return names
}

View file

@ -15,6 +15,7 @@ import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicip
import { getMunicipalityOverview } from '@/utilities/ai/tools/getMunicipalityOverview'
import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
import { getOrganizations } from '@/utilities/ai/tools/getOrganizations'
import { getPendingLeaderships } from '@/utilities/ai/tools/getPendingLeaderships'
import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
import { searchEntities } from '@/utilities/ai/tools/searchEntities'
@ -27,6 +28,7 @@ export const buildAITools = (ctx: AIToolContext) => ({
getDobradinhas: getDobradinhas(ctx),
getMunicipalityOverview: getMunicipalityOverview(ctx),
getLeaderships: getLeaderships(ctx),
getPendingLeaderships: getPendingLeaderships(ctx),
getOrganizations: getOrganizations(ctx),
searchEntities: searchEntities(ctx),
})

View file

@ -558,6 +558,11 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
// ids (a domain filter over a known set, not an actor access scope); the
// single-id form the dossier uses goes through `buildActivityListWhere`.
'src/app/(campaign)/campanha/(app)/municipios/[slug]/CityDetailSections.tsx',
// B185 — the chat tool filters leaderships/pledges by a scope resolved to
// a set of municipality ids (territory/city/municipality), a domain filter
// over a known set — the actor scope still comes from access control
// (overrideAccess: false + user).
'src/utilities/ai/tools/getPendingLeaderships.ts',
])
it('keeps the scope fragment out of re-spellings', () => {

View file

@ -0,0 +1,376 @@
import type { Payload } from 'payload'
import { describe, expect, it, vi } from 'vitest'
import type { AIToolContext } from '@/lib/ai/types'
import type { CampaignUser } from '@/payload-types'
import { getPendingLeaderships } from '@/utilities/ai/tools/getPendingLeaderships'
import { stub } from '../helpers/stub'
const denied = { error: 'Leitura de dados de lideranças 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 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 leadershipDoc = (overrides: Record<string, unknown>) => ({
id: 1,
contact: 101,
municipalities: [1],
supportStatus: 'a_abordar',
advisors: [],
updatedAt: '2026-08-01T00:00:00.000Z',
...overrides,
})
type ExecutableTool = {
execute: (args: unknown, options?: unknown) => Promise<unknown>
}
const execute =
(payload: Payload, user: CampaignUser = coordinator) =>
(args: unknown) =>
(getPendingLeaderships(ctxFor(user, payload)) as unknown as ExecutableTool).execute!(args)
const PENDING_STATUS_WHERE = { supportStatus: { in: ['a_abordar', 'em_disputa', 'engajado'] } }
describe('getPendingLeaderships gate (B185)', () => {
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 execute(scriptedPayload(find))({})) as Record<string, unknown>
expect(result.total).toBe(0)
expect(result.liderancas).toEqual([])
expect(result.truncado).toBe(false)
expect(result.criterio).toContain('Engajado')
expect(find).toHaveBeenCalledWith(
expect.objectContaining({ collection: 'leadership', overrideAccess: false }),
)
})
it('lets an advisor pass (RBAC atual via access control, não pelo gate)', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const advisor = stub<CampaignUser>({ collection: 'campaignUser', role: 'advisor' })
const result = (await execute(scriptedPayload(find), advisor)({})) as Record<string, unknown>
expect(result.total).toBe(0)
})
})
describe('getPendingLeaderships pending criterion (B185)', () => {
it('lists a_abordar/em_disputa e engajado sem pledge; exclui engajado com pledge e qualquer negativo', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
leadershipDoc({ id: 1, supportStatus: 'a_abordar' }),
leadershipDoc({ id: 2, supportStatus: 'engajado' }),
leadershipDoc({ id: 3, supportStatus: 'em_disputa' }),
leadershipDoc({ id: 4, supportStatus: 'engajado' }),
leadershipDoc({ id: 5, supportStatus: 'negativo' }),
]),
)
find.mockResolvedValueOnce(findResult([{ leadership: 2 }]))
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({})) as {
total: number
liderancas: Array<Record<string, unknown>>
}
expect(result.total).toBe(3)
expect(result.liderancas.map((l) => l.id)).toEqual([1, 3, 4])
const first = result.liderancas[0]!
expect(first).toMatchObject({
nome: 'Lider A',
status: 'A abordar',
municipios: [{ id: 1, nome: 'Amargosa', slug: 'amargosa' }],
assessores: [],
})
expect(first.ultimaAtualizacao).toBe('2026-08-01T00:00:00.000Z')
const leadershipCall = find.mock.calls[0]![0] as { where: unknown }
expect(leadershipCall.where).toEqual({ and: [PENDING_STATUS_WHERE] })
})
it('um pledge fora do escopo não satisfaz o eixo do compromisso', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, supportStatus: 'engajado' })]))
find.mockResolvedValueOnce(findResult([{ leadership: 7 }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
})) as { liderancas: Array<{ id: number }> }
expect(result.liderancas).toEqual([])
const pledgeCall = find.mock.calls[2]![0] as { where: unknown }
expect(pledgeCall.where).toEqual({
and: [{ leadership: { in: [7] } }, { municipality: { in: [10] } }],
})
})
})
describe('getPendingLeaderships scope resolution (B185)', () => {
it('resolve território com tolerância a acento ("Vale do Jiquiriça" → região canônica)', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 10,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 7, municipalities: [10] })]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
})) as { escopo: { tipo: string; nome: string }; liderancas: unknown[] }
expect(result.escopo).toEqual({ tipo: 'regiao', nome: 'Vale do Jiquiriçá' })
const scopeCall = find.mock.calls[0]![0] as { where: unknown }
expect(scopeCall.where).toEqual({ region: { equals: 'Vale do Jiquiriçá' } })
const leadershipCall = find.mock.calls[1]![0] as { where: unknown }
expect(leadershipCall.where).toEqual({
and: [PENDING_STATUS_WHERE, { municipalities: { in: [10] } }],
})
expect(result.liderancas).toHaveLength(1)
})
it('resolve "Salvador" como cidade agregando as 19 zonas', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(
findResult([
{
id: 11,
name: 'Salvador ZE 1',
slug: 'salvador-ze-1',
city: 'Salvador',
region: 'Metropolitano de Salvador',
},
{
id: 12,
name: 'Salvador ZE 2',
slug: 'salvador-ze-2',
city: 'Salvador',
region: 'Metropolitano de Salvador',
},
]),
)
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 1, municipalities: [11] })]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Salvador',
})) as { escopo: { tipo: string; nome: string } }
expect(result.escopo).toEqual({ tipo: 'cidade', nome: 'Salvador (cidade)' })
const cityCall = find.mock.calls[1]![0] as { where: unknown }
expect(cityCall.where).toEqual({ city: { equals: 'Salvador' } })
const leadershipCall = find.mock.calls[2]![0] as { where: unknown }
expect(leadershipCall.where).toEqual({
and: [PENDING_STATUS_WHERE, { municipalities: { in: [11, 12] } }],
})
})
it('resolve município por nome canônico', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 30,
name: 'Feira de Santana',
slug: 'feira-de-santana',
city: 'Feira de Santana',
region: 'Portal do Sertão',
},
]),
)
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 1, municipalities: [30] })]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Feira de Santana',
})) as { escopo: { tipo: string; nome: string } }
expect(result.escopo).toEqual({ tipo: 'municipio', nome: 'Feira de Santana' })
expect((find.mock.calls[0]![0] as { where: unknown }).where).toEqual({
name: { equals: 'Feira de Santana' },
})
})
it('escopo desconhecido devolve erro sem consultar lideranças', async () => {
const find = vi.fn()
const result = (await execute(scriptedPayload(find))({
escopo: 'Lugar Inexistente',
})) as { error: string }
expect(result.error).toContain('Escopo não reconhecido')
expect(find).not.toHaveBeenCalled()
})
})
describe('getPendingLeaderships filtros e modos (B185)', () => {
it('filtro sem_assessor entra no where como ausência de advisors', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([leadershipDoc({ id: 1, advisors: [] })]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
filtro: 'sem_assessor',
})) as { filtroAplicado: { semAssessor: boolean }; liderancas: unknown[] }
expect(result.filtroAplicado).toEqual({ semAssessor: true })
const leadershipCall = find.mock.calls[0]![0] as { where: unknown }
expect(leadershipCall.where).toEqual({
and: [PENDING_STATUS_WHERE, { advisors: { exists: false } }],
})
expect(result.liderancas).toHaveLength(1)
})
it('modo municipios_sem_lideranca lista os municípios do escopo sem nenhuma liderança', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
{
id: 1,
name: 'Amargosa',
slug: 'amargosa',
city: 'Amargosa',
region: 'Vale do Jiquiriçá',
},
{ id: 2, name: 'Brejões', slug: 'brejoes', city: 'Brejões', region: 'Vale do Jiquiriçá' },
{
id: 3,
name: 'Cravolândia',
slug: 'cravolandia',
city: 'Cravolândia',
region: 'Vale do Jiquiriçá',
},
]),
)
find.mockResolvedValueOnce(findResult([{ municipalities: [1, 2] }, { municipalities: [1] }]))
find.mockResolvedValue(findResult([]))
const result = (await execute(scriptedPayload(find))({
escopo: 'Vale do Jiquiriça',
modo: 'municipios_sem_lideranca',
})) as {
total: number
criterio: string
municipios: Array<{ id: number; nome: string; slug: string }>
}
expect(result.total).toBe(1)
expect(result.criterio).toContain('sem nenhuma liderança')
expect(result.municipios).toEqual([
{
id: 3,
nome: 'Cravolândia',
slug: 'cravolandia',
regiao: 'Vale do Jiquiriçá',
cidade: 'Cravolândia',
},
])
const coveredCall = find.mock.calls[1]![0] as { where: unknown }
expect(coveredCall.where).toEqual({ municipalities: { in: [1, 2, 3] } })
})
it('rejeita combinar o filtro sem_assessor com o modo de municípios', async () => {
const find = vi.fn()
const result = (await execute(scriptedPayload(find))({
modo: 'municipios_sem_lideranca',
filtro: 'sem_assessor',
})) as { error: string }
expect(result.error).toContain('só se aplica à lista de lideranças')
expect(find).not.toHaveBeenCalled()
})
it('trunca no limite mantendo o total real', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([
leadershipDoc({ id: 1, supportStatus: 'a_abordar' }),
leadershipDoc({ id: 2, supportStatus: 'a_abordar' }),
leadershipDoc({ id: 3, supportStatus: 'a_abordar' }),
]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValue(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
const result = (await execute(scriptedPayload(find))({ limit: 2 })) as {
total: number
truncado: boolean
liderancas: unknown[]
}
expect(result.total).toBe(3)
expect(result.truncado).toBe(true)
expect(result.liderancas).toHaveLength(2)
})
it('nomes de assessores são resolvidos em query própria (nunca depth populando campaignUser)', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(
findResult([leadershipDoc({ id: 1, advisors: [501, 502], supportStatus: 'em_disputa' })]),
)
find.mockResolvedValueOnce(findResult([]))
find.mockResolvedValueOnce(findResult([{ id: 101, name: 'Lider A' }]))
find.mockResolvedValueOnce(findResult([{ id: 1, name: 'Amargosa', slug: 'amargosa' }]))
find.mockResolvedValueOnce(
findResult([
{ id: 501, name: 'João Assessor' },
{ id: 502, name: 'Maria Assessora' },
]),
)
const result = (await execute(scriptedPayload(find))({})) as {
liderancas: Array<{ assessores: Array<{ id: number; nome: string | null }> }>
}
expect(result.liderancas[0]!.assessores).toEqual([
{ id: 501, nome: 'João Assessor' },
{ id: 502, nome: 'Maria Assessora' },
])
const advisorCall = find.mock.calls[4]![0] as { collection: string; select: unknown }
expect(advisorCall.collection).toBe('campaignUser')
expect(advisorCall.select).toEqual({ name: true })
})
})