mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
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:
parent
59022b6f77
commit
03a2f57d03
7 changed files with 924 additions and 0 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-10):** **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)
|
||||
|
|
|
|||
96
docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md
Normal file
96
docs/plans/sollinha-liderancas-pendentes-abordagem-impl.md
Normal 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)
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
437
src/utilities/ai/tools/getPendingLeaderships.ts
Normal file
437
src/utilities/ai/tools/getPendingLeaderships.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
376
tests/unit/pendingLeadershipsTool.unit.spec.ts
Normal file
376
tests/unit/pendingLeadershipsTool.unit.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue