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> => { + const names = new Map() + 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> => { + const names = new Map() + 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 +} diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index 314cf3e5..b9a154d2 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), }) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index ad468ec2..185ee867 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/pendingLeadershipsTool.unit.spec.ts b/tests/unit/pendingLeadershipsTool.unit.spec.ts new file mode 100644 index 00000000..d1c1973b --- /dev/null +++ b/tests/unit/pendingLeadershipsTool.unit.spec.ts @@ -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 + +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 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 leadershipDoc = (overrides: Record) => ({ + 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 +} + +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 + 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({ collection: 'campaignUser', role: 'advisor' }) + const result = (await execute(scriptedPayload(find), advisor)({})) as Record + 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> + } + + 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 }) + }) +})